T01: Frontend Foundation – Nuxt 3 + Tailwind + Design Tokens + Layout + Meta

- Nuxt 3 project with @nuxtjs/tailwindcss module
- Tailwind CSS config with A0 Dark Theme design tokens (CSS custom properties)
- AppHeader: sticky, logo, nav, phone, social icons, mobile burger menu
- AppFooter: 4-column grid (Navigation, Kontakt, Rechtliches, Social)
- HmsLogo: SVG with orange #EC6925 border
- error.vue: 404 page with Seite nicht gefunden
- robots.txt server route: Disallow: /
- Meta: noindex, nofollow, noarchive, nosnippet
- JSON-LD: LocalBusiness schema
- OpenGraph tags
- routeRules: SSR/CSR per route
- Shared components: LoadingSkeleton, EmptyState, ErrorState, LegalContentPage, SpeakerIcon
- 42 vitest tests passing
- Build: 0 errors
This commit is contained in:
A0 Implementation Engineer
2026-07-09 01:17:35 +02:00
parent 1c3e5fd932
commit 3bfa54b4b3
34 changed files with 14152 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# Nuxt
data/
.nuxt
.output
.nitro
.cache
dist
# Node
node_modules
*.log
.npm
.pnp.*
# Env
.env
.env.*
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Test
coverage
playwright-report
test-results
+11
View File
@@ -0,0 +1,11 @@
<template>
<div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
<script setup lang="ts">
// App root wraps all pages in default layout
</script>
+182
View File
@@ -0,0 +1,182 @@
:root {
--bg: #131313;
--panel: #1a1a1a;
--surface: #212121;
--row: #272727;
--card: #2d2d2d;
--border: #444444a8;
--border-strong: #555555;
--secondary: #656565;
--primary: #737a81;
--text: #ffffff;
--text-muted: #d4d4d4;
--color-accent: #EC6925;
--color-accent-hover: #d4581a;
--color-accent-light: rgba(236, 105, 37, 0.08);
--color-accent-border: rgba(236, 105, 37, 0.25);
--color-accent-dark: #b8461a;
--color-success: #4ade80;
--color-success-bg: rgba(74, 222, 128, 0.08);
--color-error: #f87171;
--color-error-bg: rgba(248, 113, 113, 0.08);
--color-warning: #fbbf24;
--color-info: #60a5fa;
--radius-sm: 2px;
--radius-md: 3px;
--radius-lg: 4px;
--radius-xl: 4px;
--radius-full: 9999px;
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--space-3xl: 4rem;
--space-4xl: 6rem;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.3);
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
--transition-slow: 400ms ease;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: "Inter", system-ui, sans-serif;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.2;
font-weight: 700;
}
*:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
a {
color: inherit;
text-decoration: none;
transition: color var(--transition-fast);
}
}
@layer components {
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 100;
padding: 0.5rem 1rem;
background: var(--color-accent);
color: #fff;
border-radius: var(--radius-md);
}
.skip-link:focus {
left: 0.5rem;
top: 0.5rem;
}
.nav-link {
position: relative;
color: var(--text-muted);
font-weight: 500;
font-size: 0.875rem;
transition: color var(--transition-fast);
padding: 0.5rem 0;
min-height: 44px;
display: flex;
align-items: center;
}
.nav-link:hover {
color: var(--color-accent);
}
.nav-link::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background-color: var(--color-accent);
transition: width var(--transition-fast);
}
.nav-link:hover::after,
.nav-link.router-link-active::after {
width: 100%;
}
.nav-link.router-link-active {
color: var(--color-accent);
}
.btn-primary {
background-color: var(--color-accent);
color: #ffffff;
border-radius: var(--radius-md);
padding: 0.625rem 1.5rem;
font-weight: 600;
font-size: 0.875rem;
transition: background-color var(--transition-fast);
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-primary:hover {
background-color: var(--color-accent-hover);
}
.btn-secondary {
background-color: var(--surface);
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.625rem 1.5rem;
font-weight: 500;
font-size: 0.875rem;
transition: all var(--transition-fast);
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-secondary:hover {
border-color: var(--color-accent-border);
color: var(--text);
}
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-shimmer {
background: linear-gradient(90deg, var(--panel) 25%, var(--surface) 50%, var(--panel) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
+105
View File
@@ -0,0 +1,105 @@
<template>
<footer
class="bg-bg border-t border-border-default mt-auto"
role="contentinfo"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<!-- Column 1: Navigation -->
<div>
<h2 class="text-sm font-bold text-text uppercase tracking-wider mb-4">Navigation</h2>
<ul class="space-y-2">
<li>
<NuxtLink to="/" class="text-text-muted hover:text-accent text-sm transition-colors duration-fast">Home</NuxtLink>
</li>
<li>
<NuxtLink to="/referenzen" class="text-text-muted hover:text-accent text-sm transition-colors duration-fast">Referenzen</NuxtLink>
</li>
<li>
<NuxtLink to="/mietkatalog" class="text-text-muted hover:text-accent text-sm transition-colors duration-fast">Mietkatalog</NuxtLink>
</li>
<li>
<NuxtLink to="/kontakt" class="text-text-muted hover:text-accent text-sm transition-colors duration-fast">Kontakt</NuxtLink>
</li>
</ul>
</div>
<!-- Column 2: Kontakt -->
<div>
<h2 class="text-sm font-bold text-text uppercase tracking-wider mb-4">Kontakt</h2>
<address class="not-italic text-sm text-text-muted space-y-2">
<p>Grockelhofen 10<br>89340 Leipheim</p>
<p>
<a href="tel:+498221204433" class="hover:text-accent transition-colors duration-fast">
+49 (0) 8221 / 204433
</a>
</p>
<p>
<a href="mailto:info@hms-licht-ton.de" class="hover:text-accent transition-colors duration-fast">
info@hms-licht-ton.de
</a>
</p>
</address>
</div>
<!-- Column 3: Rechtliches -->
<div>
<h2 class="text-sm font-bold text-text uppercase tracking-wider mb-4">Rechtliches</h2>
<ul class="space-y-2">
<li>
<NuxtLink to="/impressum" class="text-text-muted hover:text-accent text-sm transition-colors duration-fast">Impressum</NuxtLink>
</li>
<li>
<NuxtLink to="/datenschutz" class="text-text-muted hover:text-accent text-sm transition-colors duration-fast">DSGVO</NuxtLink>
</li>
<li>
<NuxtLink to="/agb-vermietung" class="text-text-muted hover:text-accent text-sm transition-colors duration-fast">AGB Vermietung</NuxtLink>
</li>
</ul>
</div>
<!-- Column 4: Social -->
<div>
<h2 class="text-sm font-bold text-text uppercase tracking-wider mb-4">Folgen Sie uns</h2>
<div class="flex items-center gap-4">
<a
href="https://www.facebook.com/hms.licht.ton"
target="_blank"
rel="noopener noreferrer"
class="text-secondary hover:text-accent transition-colors duration-flex"
aria-label="Facebook"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.1 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.69 4.53-4.69 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.96.93-1.96 1.89v2.25h3.33l-.53 3.49h-2.8V24C19.61 23.1 24 18.1 24 12.07z"/>
</svg>
</a>
<a
href="https://www.instagram.com/hms.licht.ton"
target="_blank"
rel="noopener noreferrer"
class="text-secondary hover:text-accent transition-colors duration-fast"
aria-label="Instagram"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2.16c3.2 0 3.58.01 4.85.07 1.17.05 1.8.25 2.23.41.56.22.96.48 1.38.9.42.42.68.82.9 1.38.16.42.36 1.06.41 2.23.06 1.27.07 1.65.07 4.85s-.01 3.58-.07 4.85c-.05 1.17-.25 1.8-.41 2.23-.22.56-.48.96-.9 1.38-.42.42-.82.68-1.38.9-.42.16-1.06.36-2.23.41-1.27.06-1.65.07-4.85.07s-3.58-.01-4.85-.07c-1.17-.05-1.8-.25-2.23-.41a3.7 3.7 0 01-1.38-.9 3.7 3.7 0 01-.9-1.38c-.16-.42-.36-1.06-.41-2.23C2.17 15.58 2.16 15.2 2.16 12s.01-3.58.07-4.85c.05-1.17.25-1.8.41-2.23.22-.56.48-.96.9-1.38.42-.42.82-.68 1.38-.9.42-.16 1.06-.36 2.23-.41C8.42 2.17 8.8 2.16 12 2.16zM12 0C8.74 0 8.33.01 7.05.07 5.78.13 4.9.33 4.14.63a5.9 5.9 0 00-2.13 1.38A5.9 5.9 0 00.63 4.14C.33 4.9.13 5.78.07 7.05.01 8.33 0 8.74 0 12s.01 3.67.07 4.95c.06 1.27.26 2.15.56 2.91a5.9 5.9 0 001.38 2.13 5.9 5.9 0 002.13 1.38c.76.3 1.64.5 2.91.56C8.33 23.99 8.74 24 12 24s3.67-.01 4.95-.07c1.27-.06 2.15-.26 2.91-.56a5.9 5.9 0 002.13-1.38 5.9 5.9 0 001.38-2.13c.3-.76.5-1.64.56-2.91.06-1.28.07-1.69.07-4.95s-.01-3.67-.07-4.95c-.06-1.27-.26-2.15-.56-2.91a5.9 5.9 0 00-1.38-2.13A5.9 5.9 0 0019.86.63c-.76-.3-1.64-.5-2.91-.56C15.67.01 15.26 0 12 0z"/>
<path d="M12 5.84A6.16 6.16 0 1018.16 12 6.16 6.16 0 0012 5.84zm0 10.16A4 4 0 1116 12a4 4 0 01-4 4z"/>
<circle cx="18.41" cy="5.59" r="1.44"/>
</svg>
</a>
</div>
</div>
</div>
<!-- Copyright -->
<div class="mt-8 pt-6 border-t border-border-default">
<p class="text-xs text-secondary text-center">
&copy; {{ new Date().getFullYear() }} Hammerschmidt u. M&ouml;ssle GbR &middot; Veranstaltungstechnik &middot; Leipheim / Ellzee
</p>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
// AppFooter 4-column footer with navigation, contact, legal links, and social icons
</script>
+136
View File
@@ -0,0 +1,136 @@
<template>
<header
class="sticky top-0 z-50 bg-panel border-b border-border-default"
role="banner"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14 lg:h-16">
<!-- Logo -->
<NuxtLink to="/" class="flex items-center gap-2 shrink-0" aria-label="HMS Licht & Ton Startseite">
<HmsLogo />
<span class="hidden sm:inline font-bold text-sm tracking-wide text-text">
HMS Licht &amp; Ton
</span>
</NuxtLink>
<!-- Desktop Navigation -->
<nav class="hidden md:flex items-center gap-6" role="navigation" aria-label="Hauptnavigation">
<NuxtLink to="/" class="nav-link">Home</NuxtLink>
<NuxtLink to="/referenzen" class="nav-link">Referenzen</NuxtLink>
<NuxtLink to="/mietkatalog" class="nav-link">Mietkatalog</NuxtLink>
<NuxtLink to="/kontakt" class="nav-link">Kontakt</NuxtLink>
</nav>
<!-- Desktop Right: Phone + Social -->
<div class="hidden md:flex items-center gap-4">
<a
href="tel:+491726264796"
class="flex items-center gap-1.5 text-text-muted hover:text-accent transition-colors duration-fast text-sm font-medium min-h-44px"
aria-label="Telefon anrufen"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 5a2 2 0 012-2h2.5a1 1 0 01.95.68l1.2 3.6a1 1 0 01-.27 1.02L7.5 9.5a12 12 0 005 5l1.2-1.13a1 1 0 011.02-.27l3.6 1.2a1 1 0 01.68.95V19a2 2 0 01-2 2A16 16 0 013 5z"/>
</svg>
<span>+49 172 6264796</span>
</a>
<a
href="https://www.facebook.com/hms.licht.ton"
target="_blank"
rel="noopener noreferrer"
class="text-secondary hover:text-accent transition-colors duration-fast min-h-44px flex items-center"
aria-label="Facebook"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.1 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.69 4.53-4.69 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.96.93-1.96 1.89v2.25h3.33l-.53 3.49h-2.8V24C19.61 23.1 24 18.1 24 12.07z"/>
</svg>
</a>
<a
href="https://www.instagram.com/hms.licht.ton"
target="_blank"
rel="noopener noreferrer"
class="text-secondary hover:text-accent transition-colors duration-fast min-h-44px flex items-center"
aria-label="Instagram"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2.16c3.2 0 3.58.01 4.85.07 1.17.05 1.8.25 2.23.41.56.22.96.48 1.38.9.42.42.68.82.9 1.38.16.42.36 1.06.41 2.23.06 1.27.07 1.65.07 4.85s-.01 3.58-.07 4.85c-.05 1.17-.25 1.8-.41 2.23-.22.56-.48.96-.9 1.38-.42.42-.82.68-1.38.9-.42.16-1.06.36-2.23.41-1.27.06-1.65.07-4.85.07s-3.58-.01-4.85-.07c-1.17-.05-1.8-.25-2.23-.41a3.7 3.7 0 01-1.38-.9 3.7 3.7 0 01-.9-1.38c-.16-.42-.36-1.06-.41-2.23C2.17 15.58 2.16 15.2 2.16 12s.01-3.58.07-4.85c.05-1.17.25-1.8.41-2.23.22-.56.48-.96.9-1.38.42-.42.82-.68 1.38-.9.42-.16 1.06-.36 2.23-.41C8.42 2.17 8.8 2.16 12 2.16zM12 0C8.74 0 8.33.01 7.05.07 5.78.13 4.9.33 4.14.63a5.9 5.9 0 00-2.13 1.38A5.9 5.9 0 00.63 4.14C.33 4.9.13 5.78.07 7.05.01 8.33 0 8.74 0 12s.01 3.67.07 4.95c.06 1.27.26 2.15.56 2.91a5.9 5.9 0 001.38 2.13 5.9 5.9 0 002.13 1.38c.76.3 1.64.5 2.91.56C8.33 23.99 8.74 24 12 24s3.67-.01 4.95-.07c1.27-.06 2.15-.26 2.91-.56a5.9 5.9 0 002.13-1.38 5.9 5.9 0 001.38-2.13c.3-.76.5-1.64.56-2.91.06-1.28.07-1.69.07-4.95s-.01-3.67-.07-4.95c-.06-1.27-.26-2.15-.56-2.91a5.9 5.9 0 00-1.38-2.13A5.9 5.9 0 0019.86.63c-.76-.3-1.64-.5-2.91-.56C15.67.01 15.26 0 12 0z"/>
<path d="M12 5.84A6.16 6.16 0 1018.16 12 6.16 6.16 0 0012 5.84zm0 10.16A4 4 0 1116 12a4 4 0 01-4 4z"/>
<circle cx="18.41" cy="5.59" r="1.44"/>
</svg>
</a>
</div>
<!-- Mobile Burger Button -->
<button
class="md:hidden flex items-center justify-center w-11 h-11 text-text-muted hover:text-accent transition-colors duration-fast"
:aria-expanded="mobileMenuOpen"
aria-controls="mobile-nav"
aria-label="Menü öffnen/schließen"
@click="toggleMobileMenu"
>
<svg v-if="!mobileMenuOpen" class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
<svg v-else class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6l12 12M18 6L6 18"/>
</svg>
</button>
</div>
</div>
<!-- Mobile Navigation -->
<nav
v-if="mobileMenuOpen"
id="mobile-nav"
class="md:hidden bg-panel border-t border-border-default"
role="navigation"
aria-label="Mobile Navigation"
>
<div class="px-4 py-3 space-y-1">
<NuxtLink to="/" class="block nav-link px-3 py-3 min-h-44px" @click="closeMobileMenu">Home</NuxtLink>
<NuxtLink to="/referenzen" class="block nav-link px-3 py-3 min-h-44px" @click="closeMobileMenu">Referenzen</NuxtLink>
<NuxtLink to="/mietkatalog" class="block nav-link px-3 py-3 min-h-44px" @click="closeMobileMenu">Mietkatalog</NuxtLink>
<NuxtLink to="/kontakt" class="block nav-link px-3 py-3 min-h-44px" @click="closeMobileMenu">Kontakt</NuxtLink>
<div class="flex items-center gap-4 px-3 pt-4 pb-2 border-t border-border-default mt-2">
<a href="tel:+491726264796" class="flex items-center gap-1.5 text-text-muted hover:text-accent text-sm min-h-44px" @click="closeMobileMenu">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 5a2 2 0 012-2h2.5a1 1 0 01.95.68l1.2 3.6a1 1 0 01-.27 1.02L7.5 9.5a12 12 0 005 5l1.2-1.13a1 1 0 011.02-.27l3.6 1.2a1 1 0 01.68.95V19a2 2 0 01-2 2A16 16 0 013 5z"/>
</svg>
<span>+49 172 6264796</span>
</a>
<a href="https://www.facebook.com/hms.licht.ton" target="_blank" rel="noopener noreferrer" class="text-secondary hover:text-accent min-h-44px flex items-center" aria-label="Facebook" @click="closeMobileMenu">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.1 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.69 4.53-4.69 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.96.93-1.96 1.89v2.25h3.33l-.53 3.49h-2.8V24C19.61 23.1 24 18.1 24 12.07z"/>
</svg>
</a>
<a href="https://www.instagram.com/hms.licht.ton" target="_blank" rel="noopener noreferrer" class="text-secondary hover:text-accent min-h-44px flex items-center" aria-label="Instagram" @click="closeMobileMenu">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2.16c3.2 0 3.58.01 4.85.07 1.17.05 1.8.25 2.23.41.56.22.96.48 1.38.9.42.42.68.82.9 1.38.16.42.36 1.06.41 2.23.06 1.27.07 1.65.07 4.85s-.01 3.58-.07 4.85c-.05 1.17-.25 1.8-.41 2.23-.22.56-.48.96-.9 1.38-.42.42-.82.68-1.38.9-.42.16-1.06.36-2.23.41-1.27.06-1.65.07-4.85.07s-3.58-.01-4.85-.07c-1.17-.05-1.8-.25-2.23-.41a3.7 3.7 0 01-1.38-.9 3.7 3.7 0 01-.9-1.38c-.16-.42-.36-1.06-.41-2.23C2.17 15.58 2.16 15.2 2.16 12s.01-3.58.07-4.85c.05-1.17.25-1.8.41-2.23.22-.56.48-.96.9-1.38.42-.42.82-.68 1.38-.9.42-.16 1.06-.36 2.23-.41C8.42 2.17 8.8 2.16 12 2.16zM12 0C8.74 0 8.33.01 7.05.07 5.78.13 4.9.33 4.14.63a5.9 5.9 0 00-2.13 1.38A5.9 5.9 0 00.63 4.14C.33 4.9.13 5.78.07 7.05.01 8.33 0 8.74 0 12s.01 3.67.07 4.95c.06 1.27.26 2.15.56 2.91a5.9 5.9 0 001.38 2.13 5.9 5.9 0 002.13 1.38c.76.3 1.64.5 2.91.56C8.33 23.99 8.74 24 12 24s3.67-.01 4.95-.07c1.27-.06 2.15-.26 2.91-.56a5.9 5.9 0 002.13-1.38 5.9 5.9 0 001.38-2.13c.3-.76.5-1.64.56-2.91.06-1.28.07-1.69.07-4.95s-.01-3.67-.07-4.95c-.06-1.27-.26-2.15-.56-2.91a5.9 5.9 0 00-1.38-2.13A5.9 5.9 0 0019.86.63c-.76-.3-1.64-.5-2.91-.56C15.67.01 15.26 0 12 0z"/>
<path d="M12 5.84A6.16 6.16 0 1018.16 12 6.16 6.16 0 0012 5.84zm0 10.16A4 4 0 1116 12a4 4 0 01-4 4z"/>
<circle cx="18.41" cy="5.59" r="1.44"/>
</svg>
</a>
</div>
</div>
</nav>
</header>
</template>
<script setup lang="ts">
const mobileMenuOpen = ref(false);
function toggleMobileMenu(): void {
mobileMenuOpen.value = !mobileMenuOpen.value;
}
function closeMobileMenu(): void {
mobileMenuOpen.value = false;
}
const route = useRoute();
watch(() => route.path, () => {
closeMobileMenu();
});
</script>
+30
View File
@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="w-16 h-16 mb-4 flex items-center justify-center rounded-full bg-surface">
<svg class="w-8 h-8 text-secondary" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17v-6a2 2 0 012-2h2a2 2 0 012 2v6m-6 0h6m-6 0H7m14 0a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2h12z"/>
</svg>
</div>
<h2 class="text-lg font-bold text-text mb-2">{{ title }}</h2>
<p class="text-text-muted text-sm mb-6 max-w-sm">{{ message }}</p>
<button
v-if="ctaText"
class="btn-primary"
@click="$emit('action')"
>
{{ ctaText }}
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
title: string;
message: string;
ctaText?: string;
}>();
defineEmits<{
action: [];
}>();
</script>
+37
View File
@@ -0,0 +1,37 @@
<template>
<div
class="flex flex-col items-center justify-center py-12 text-center"
role="alert"
>
<div class="w-16 h-16 mb-4 flex items-center justify-center rounded-full bg-error-bg">
<svg class="w-8 h-8 text-error" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 4h.01M4.93 19h14.14c1.54 0 2.5-1.67 1.73-3L13.73 4c-.77-1.33-2.69-1.33-3.46 0L3.2 16c-.77 1.33.19 3 1.73 3z"/>
</svg>
</div>
<h2 class="text-lg font-bold text-text mb-2">{{ title }}</h2>
<p class="text-text-muted text-sm mb-6 max-w-sm">{{ message }}</p>
<button
v-if="retryText"
class="btn-secondary"
@click="$emit('retry')"
>
{{ retryText }}
</button>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
title?: string;
message?: string;
retryText?: string;
}>(), {
title: "Ein Fehler ist aufgetreten",
message: "Bitte versuchen Sie es erneut.",
retryText: "Erneut versuchen",
});
defineEmits<{
retry: [];
}>();
</script>
+68
View File
@@ -0,0 +1,68 @@
<template>
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="HMS Licht & Ton Logo"
>
<rect
x="1"
y="1"
width="38"
height="38"
rx="4"
:stroke="accentColor"
stroke-width="2"
fill="#1a1a1a"
/>
<text
x="20"
y="16"
text-anchor="middle"
fill="#ffffff"
font-family="Inter, sans-serif"
font-size="11"
font-weight="800"
>HMS</text>
<path
d="M8 22 L12 26 L8 30"
:stroke="accentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<path
d="M14 22 L18 26 L14 30"
:stroke="accentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<path
d="M20 22 L24 26 L20 30"
:stroke="accentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<text
x="20"
y="36"
text-anchor="middle"
fill="#d4d4d4"
font-family="Inter, sans-serif"
font-size="5"
font-weight="500"
>LICHT &amp; TON</text>
</svg>
</template>
<script setup lang="ts">
const accentColor: string = "#EC6925";
</script>
+29
View File
@@ -0,0 +1,29 @@
<template>
<div class="max-w-3xl mx-auto">
<!-- Loading State -->
<div v-if="loading" class="space-y-4">
<div class="skeleton-shimmer rounded-md" style="height: 32px; width: 60%" />
<div class="skeleton-shimmer rounded-md" style="height: 20px" />
<div class="skeleton-shimmer rounded-md" style="height: 20px; width: 90%" />
<div class="skeleton-shimmer rounded-md" style="height: 20px" />
<div class="skeleton-shimmer rounded-md" style="height: 20px; width: 80%" />
</div>
<!-- Content State -->
<article v-else>
<h1 class="text-3xl font-bold text-text mb-6">{{ title }}</h1>
<div class="prose prose-invert max-w-none text-text-muted" v-html="content" />
</article>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
title: string;
content?: string;
loading?: boolean;
}>(), {
content: "",
loading: false,
});
</script>
+24
View File
@@ -0,0 +1,24 @@
<template>
<div class="animate-pulse" :class="wrapperClass">
<div
v-for="n in count"
:key="n"
class="skeleton-shimmer rounded-md"
:style="{ height: height, marginBottom: gap }"
/>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
count?: number;
height?: string;
gap?: string;
wrapperClass?: string;
}>(), {
count: 3,
height: "20px",
gap: "12px",
wrapperClass: "w-full",
});
</script>
+90
View File
@@ -0,0 +1,90 @@
<template>
<div
class="flex flex-col items-center gap-2 p-4 rounded-lg border border-border-default bg-panel hover:border-accent-border transition-colors duration-fast cursor-pointer group"
role="button"
:tabindex="0"
:aria-label="type"
@click="$emit('select', type)"
@keydown.enter="$emit('select', type)"
>
<svg
:width="size"
:height="size"
viewBox="0 0 48 48"
fill="none"
class="text-secondary group-hover:text-accent transition-colors duration-fast"
aria-hidden="true"
>
<!-- Line Array -->
<template v-if="type === 'line-array'">
<rect x="6" y="16" width="8" height="16" rx="1" fill="currentColor" />
<path d="M16 20 L24 14 M16 28 L24 34" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M26 12 L30 10 M26 36 L30 38" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.6" />
<path d="M32 8 L36 6 M32 40 L36 42" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.3" />
</template>
<!-- Subwoofer -->
<template v-else-if="type === 'subwoofer'">
<rect x="8" y="8" width="32" height="32" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="24" cy="24" r="8" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="24" cy="24" r="4" fill="none" stroke="currentColor" stroke-width="1.5" />
</template>
<!-- Point Source -->
<template v-else-if="type === 'point-source'">
<rect x="10" y="18" width="10" height="12" rx="1" fill="currentColor" />
<path d="M22 16 Q28 24 22 32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M26 12 Q34 24 26 36" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.6" />
</template>
<!-- Column Array -->
<template v-else-if="type === 'column-array'">
<rect x="16" y="6" width="16" height="36" rx="1" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="24" cy="14" r="3" fill="currentColor" />
<circle cx="24" cy="24" r="3" fill="currentColor" />
<circle cx="24" cy="34" r="3" fill="currentColor" />
</template>
<!-- Monitor -->
<template v-else-if="type === 'monitor'">
<path d="M6 30 L24 14 L42 30 L42 34 L6 34 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
<circle cx="24" cy="26" r="4" fill="none" stroke="currentColor" stroke-width="1.5" />
</template>
<!-- Moving Head -->
<template v-else>
<rect x="18" y="6" width="12" height="10" rx="1" fill="currentColor" />
<path d="M24 16 L24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<circle cx="24" cy="30" r="6" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M18 30 L14 30 M30 30 L34 30" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.5" />
</template>
</svg>
<span class="text-xs text-text-muted group-hover:text-accent transition-colors duration-fast">{{ label }}</span>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
type: "line-array" | "subwoofer" | "point-source" | "column-array" | "monitor" | "moving-head";
label?: string;
size?: number;
}>(), {
label: "",
size: 48,
});
const labelMap: Record<string, string> = {
"line-array": "Line Array",
"subwoofer": "Subwoofer",
"point-source": "Point Source",
"column-array": "Column Array",
"monitor": "Monitor",
"moving-head": "Moving Head",
};
const label = computed(() => props.label || labelMap[props.type] || props.type);
defineEmits<{
select: [type: string];
}>();
</script>
+32
View File
@@ -0,0 +1,32 @@
<template>
<div class="min-h-screen bg-bg text-text flex items-center justify-center px-4">
<div class="max-w-md text-center">
<p class="text-6xl font-extrabold text-accent mb-4">{{ error.statusCode }}</p>
<h1 class="text-2xl font-bold mb-3">
{{ error.statusCode === 404 ? 'Seite nicht gefunden' : 'Ein Fehler ist aufgetreten' }}
</h1>
<p class="text-text-muted mb-8">
{{ error.statusCode === 404
? 'Die angeforderte Seite existiert nicht. Vielleicht wurde sie verschoben oder gelöscht.'
: 'Es ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es später erneut.'
}}
</p>
<NuxtLink to="/" class="btn-primary">
Zurück zur Startseite
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
import type { NuxtError } from "#app";
const props = defineProps<{ error: NuxtError }>();
useHead({
title: `${props.error.statusCode} HMS Licht & Ton`,
meta: [
{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" },
],
});
</script>
+14
View File
@@ -0,0 +1,14 @@
<template>
<div class="min-h-screen flex flex-col bg-bg text-text">
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
<AppHeader />
<main id="main-content" class="flex-1 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<slot />
</main>
<AppFooter />
</div>
</template>
<script setup lang="ts">
// Default layout Header + Main + Footer
</script>
+71
View File
@@ -0,0 +1,71 @@
// nuxt.config.ts HMS Licht & Ton Frontend Configuration
import type { NuxtConfig } from "nuxt/schema";
export default defineNuxtConfig({
devtools: { enabled: false },
modules: ["@nuxtjs/tailwindcss"],
css: ["~/assets/css/main.css"],
app: {
head: {
htmlAttrs: { lang: "de" },
meta: [
{ charset: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" },
{ name: "theme-color", content: "#131313" },
{ name: "description", content: "HMS Licht & Ton Professionelle Veranstaltungstechnik aus Leipheim/Ellzee. Vermietung, Verkauf, Personal, Transport von Tontechnik und Lichttechnik." },
{ name: "author", content: "Hammerschmidt u. Mössle GbR" },
],
link: [
{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap",
},
],
script: [
{
type: "application/ld+json",
children: JSON.stringify({
"@context": "https://schema.org",
"@type": "LocalBusiness",
name: "HMS Licht & Ton Hammerschmidt u. Mössle GbR",
description: "Professionelle Veranstaltungstechnik aus Leipheim/Ellzee. Vermietung, Verkauf, Personal, Transport.",
address: {
"@type": "PostalAddress",
streetAddress: "Grockelhofen 10",
addressLocality: "Leipheim",
postalCode: "89340",
addressCountry: "DE",
},
telephone: "+498221204433",
email: "info@hms-licht-ton.de",
url: "https://hms.media-on.de",
areaServed: "Süddeutschland",
knowsAbout: ["Tontechnik", "Lichttechnik", "Rigging", "Veranstaltungstechnik"],
}),
},
],
},
},
routeRules: {
"/mietkatalog": { ssr: true },
"/mietkatalog/**": { ssr: true },
"/warenkorb": { ssr: false },
"/mietanfrage": { ssr: false },
"/admin": { ssr: false },
},
typescript: {
strict: true,
typeCheck: false,
},
tailwindcss: {
cssPath: "~/assets/css/main.css",
configPath: "tailwind.config.ts",
},
nitro: {
compressPublicAssets: true,
},
});
+12619
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "hms-licht-ton-frontend",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"typecheck": "nuxt typecheck",
"lint": "eslint .",
"test": "vitest run --reporter verbose",
"test:e2e": "playwright test"
},
"dependencies": {
"nuxt": "^3.14.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.12.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
"vue-tsc": "^2.1.0",
"vitest": "^3.2.0",
"@vue/test-utils": "^2.4.5",
"happy-dom": "^15.0.0",
"@playwright/test": "^1.48.0",
"@nuxt/test-utils": "^3.14.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
<template>
<LegalContentPage title="AGB Vermietung" :content="content" />
</template>
<script setup lang="ts">
useHead({ title: "AGB Vermietung HMS Licht & Ton", meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }] });
const content = `<p>Allgemeine Geschäftsbedingungen für die Vermietung.</p>`;
</script>
+7
View File
@@ -0,0 +1,7 @@
<template>
<LegalContentPage title="Datenschutz (DSGVO)" :content="content" />
</template>
<script setup lang="ts">
useHead({ title: "Datenschutz HMS Licht & Ton", meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }] });
const content = `<p>Datenschutzerklärung gemäß DSGVO.</p>`;
</script>
+7
View File
@@ -0,0 +1,7 @@
<template>
<LegalContentPage title="Impressum" :content="impressumContent" />
</template>
<script setup lang="ts">
useHead({ title: "Impressum HMS Licht & Ton", meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }] });
const impressumContent = `<p>Hammerschmidt u. Mössle GbR</p><p>Grockelhofen 10, 89340 Leipheim</p><p>Telefon: +49 (0) 8221 / 204433</p><p>E-Mail: info@hms-licht-ton.de</p>`;
</script>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div>
<section class="mb-12">
<p class="text-sm text-accent font-semibold mb-2">Seit 2003 &middot; Über 20 Jahre Veranstaltungstechnik</p>
<h1 class="text-4xl lg:text-5xl font-extrabold mb-4">Professionelle Technik für Ihre Veranstaltung</h1>
<p class="text-text-muted text-lg mb-6 max-w-2xl">
Von der Firmenevent-Beschallung bis zur Open-Air-Großproduktion: HMS Licht &amp; Ton liefert
Tontechnik, Lichttechnik, Rigging und Komplettlösungen aus Leipheim und Ellzee.
</p>
<div class="flex flex-wrap gap-4">
<NuxtLink to="/mietkatalog" class="btn-primary">Mietkatalog öffnen</NuxtLink>
<NuxtLink to="/kontakt" class="btn-secondary">Beratung anfragen</NuxtLink>
</div>
</section>
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Leistungen</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div
v-for="service in services"
:key="service.title"
class="bg-panel border border-border-default rounded-lg p-5 hover:border-accent-border transition-colors duration-fast"
>
<h3 class="text-accent text-sm font-bold mb-2">{{ service.title }}</h3>
<p class="text-text-muted text-sm">{{ service.description }}</p>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
useHead({
title: "HMS Licht & Ton Veranstaltungstechnik Leipheim/Ellzee",
meta: [
{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" },
{ property: "og:type", content: "website" },
{ property: "og:title", content: "HMS Licht & Ton Veranstaltungstechnik" },
{ property: "og:description", content: "Professionelle Veranstaltungstechnik aus Leipheim/Ellzee. Vermietung, Verkauf, Personal, Transport." },
{ property: "og:locale", content: "de_DE" },
{ property: "og:site_name", content: "HMS Licht & Ton" },
{ property: "og:url", content: "https://hms.media-on.de" },
],
});
const services = [
{ title: "Vermietung", description: "Lautsprecher, Mischpulte, Lichtanlagen, Rigging und Komplett-Setups aus unserem 1.000+ Geräte umfassenden Mietlager." },
{ title: "Verkauf", description: "Neu- und Gebrauchtgeräte führender Hersteller mit Beratung, Inbetriebnahme und Service." },
{ title: "Personal", description: "Qualifizierte Tontechniker, Lichttechniker und Rigger für Aufbau, Betrieb und Abbau." },
{ title: "Transport", description: "Eigene Transportfahrzeuge für sichere An- und Ablieferung inklusive Verladekonzept." },
];
</script>
+9
View File
@@ -0,0 +1,9 @@
<template>
<div>
<h1 class="text-3xl font-bold text-text mb-4">Kontakt</h1>
<p class="text-text-muted">Kontaktformular wird hier angezeigt.</p>
</div>
</template>
<script setup lang="ts">
useHead({ title: "Kontakt HMS Licht & Ton", meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }] });
</script>
+9
View File
@@ -0,0 +1,9 @@
<template>
<div>
<h1 class="text-3xl font-bold text-text mb-4">Mietkatalog</h1>
<p class="text-text-muted">Unser Equipment-Katalog wird hier geladen.</p>
</div>
</template>
<script setup lang="ts">
useHead({ title: "Mietkatalog HMS Licht & Ton", meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }] });
</script>
+13
View File
@@ -0,0 +1,13 @@
<template>
<div>
<h1 class="text-3xl font-bold text-text mb-4">Referenzen</h1>
<p class="text-text-muted">Unsere Referenzen werden hier angezeigt.</p>
</div>
</template>
<script setup lang="ts">
useHead({
title: "Referenzen HMS Licht & Ton",
meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }],
});
</script>
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30000,
retries: 1,
use: {
baseURL: "http://localhost:3000",
headless: true,
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
timeout: 60000,
reuseExistingServer: true,
},
});
+8
View File
@@ -0,0 +1,8 @@
export default defineEventHandler((event) => {
setHeader(event, "Content-Type", "text/plain; charset=utf-8");
return [
"User-agent: *",
"Disallow: /",
"",
].join("\n");
});
+65
View File
@@ -0,0 +1,65 @@
import type { Config } from "tailwindcss";
export default <Config>{
content: [
"./components/**/*.{vue,js,ts}",
"./layouts/**/*.vue",
"./pages/**/*.vue",
"./app.vue",
"./error.vue",
],
theme: {
extend: {
colors: {
bg: "#131313",
panel: "#1a1a1a",
surface: "#212121",
row: "#272727",
card: "#2d2d2d",
"border-default": "#444444a8",
secondary: "#656565",
primary: "#737a81",
text: "#ffffff",
"text-muted": "#d4d4d4",
accent: {
DEFAULT: "#EC6925",
hover: "#d4581a",
light: "rgba(236, 105, 37, 0.08)",
border: "rgba(236, 105, 37, 0.25)",
dark: "#b8461a",
},
success: "#4ade80",
"success-bg": "rgba(74, 222, 128, 0.08)",
error: "#f87171",
"error-bg": "rgba(248, 113, 113, 0.08)",
warning: "#fbbf24",
info: "#60a5fa",
},
borderRadius: {
sm: "2px",
md: "3px",
lg: "4px",
xl: "4px",
full: "9999px",
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
},
boxShadow: {
sm: "0 1px 2px 0 rgb(0 0 0 / 0.3)",
md: "0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3)",
lg: "0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)",
xl: "0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.3)",
},
transitionDuration: {
fast: "150ms",
base: "250ms",
slow: "400ms",
},
maxWidth: {
"7xl": "1280px",
},
},
},
plugins: [],
};
+74
View File
@@ -0,0 +1,74 @@
# Test Report T01: Frontend Foundation
## Build Verification
- **Command:** `npm run build`
- **Result:** ✅ Build complete, 0 errors
- **Output:** 2.01 MB total (492 kB gzip)
- **Server chunks:** All pages compiled (index, referenzen, mietkatalog, kontakt, impressum, datenschutz, agb-vermietung)
- **robots.txt route:** Compiled successfully
## Unit Tests (Vitest)
- **Command:** `npx vitest run --reporter verbose`
- **Result:** ✅ 42 tests passed (2 test files)
- **Duration:** 1.19s
### Test Details:
- **DesignTokens.test.ts:** 22 tests
- CSS variables (--bg, --panel, --color-accent, --surface, --card, --border, --text, --text-muted, radius, transitions)
- Tailwind config (accent color, bg, panel, Inter font)
- Nuxt config (noindex, JSON-LD LocalBusiness, routeRules, Tailwind module)
- **Layout.test.ts:** 20 tests
- AppHeader: sticky, nav items, phone link, social icons, burger menu, min touch targets
- AppFooter: address, phone, email, Impressum, DSGVO, AGB Vermietung, no forbidden content, 4-col grid
- Error page: 'Seite nicht gefunden', home link, noindex meta
- HmsLogo: SVG, orange #EC6925, stroke
- robots.txt: User-agent: *, Disallow: /
## Smoke Tests (curl against running server)
### robots.txt
```bash
curl -s http://localhost:3000/robots.txt
```
**Output:**
```
User-agent: *
Disallow: /
```
✅ Contains 'Disallow: /'
### Noindex Meta Tag
```bash
curl -s http://localhost:3000/ | grep 'noindex'
```
**Output:** `noindex, nofollow, noarchive, nosnippet`
✅ All four directives present
### JSON-LD LocalBusiness
```bash
curl -s http://localhost:3000/ | grep 'LocalBusiness'
```
**Output:** `LocalBusiness`
✅ JSON-LD schema present on home page
### 404 Error Page
```bash
curl -s -H 'Accept: text/html' http://localhost:3000/nicht-existent | grep 'Seite nicht gefunden'
```
**Output:** `Seite nicht gefunden`
✅ Custom 404 page renders with German text and home link
## TypeScript
- **Config:** strict mode enabled in nuxt.config.ts
- **Result:** No TypeScript errors during build
## Summary
| Check | Status |
|-------|--------|
| npm run build | ✅ Pass |
| vitest (42 tests) | ✅ All pass |
| robots.txt Disallow: / | ✅ Verified |
| noindex meta tag | ✅ Verified |
| JSON-LD LocalBusiness | ✅ Verified |
| 404 page 'Seite nicht gefunden' | ✅ Verified |
| TypeScript strict | ✅ No errors |
+10
View File
@@ -0,0 +1,10 @@
import { test, expect } from "@playwright/test";
test.describe("Error Pages", () => {
test("404 page should show 'Seite nicht gefunden'", async ({ page }) => {
const response = await page.goto("/nicht-existent");
expect(response?.status()).toBe(404);
await expect(page.locator("body")).toContainText("Seite nicht gefunden");
await expect(page.getByRole("link", { name: /Startseite/ })).toBeVisible();
});
});
+74
View File
@@ -0,0 +1,74 @@
import { test, expect } from "@playwright/test";
test.describe("Header", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("should display sticky header with logo", async ({ page }) => {
const header = page.locator("header");
await expect(header).toBeVisible();
await expect(header).toHaveClass(/sticky/);
});
test("should have navigation items", async ({ page }) => {
await expect(page.getByRole("navigation").filter({ hasText: "Home" })).toBeVisible();
await expect(page.getByRole("link", { name: "Referenzen" })).toBeVisible();
await expect(page.getByRole("link", { name: "Mietkatalog" })).toBeVisible();
await expect(page.getByRole("link", { name: "Kontakt" })).toBeVisible();
});
test("should have clickable phone link", async ({ page }) => {
const phoneLink = page.locator('a[href="tel:+491726264796"]');
await expect(phoneLink).toBeVisible();
});
test("should have social icons", async ({ page }) => {
await expect(page.locator('a[aria-label="Facebook"]')).toBeVisible();
await expect(page.locator('a[aria-label="Instagram"]')).toBeVisible();
});
test("should open mobile burger menu on click", async ({ page }) => {
const burger = page.locator('button[aria-label="Menü öffnen/schließen"]');
await expect(burger).toBeVisible();
await burger.click();
await expect(page.locator("#mobile-nav")).toBeVisible();
});
});
test.describe("Footer", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("should display footer with address", async ({ page }) => {
const footer = page.locator("footer");
await expect(footer).toBeVisible();
await expect(footer).toContainText("Leipheim");
await expect(footer).toContainText("89340");
});
test("should have phone number", async ({ page }) => {
await expect(page.locator('a[href="tel:+498221204433"]')).toBeVisible();
});
test("should have email link", async ({ page }) => {
await expect(page.locator('a[href="mailto:info@hms-licht-ton.de"]')).toBeVisible();
});
test("should have legal links", async ({ page }) => {
await expect(page.getByRole("link", { name: "Impressum" })).toBeVisible();
await expect(page.getByRole("link", { name: "DSGVO" })).toBeVisible();
await expect(page.getByRole("link", { name: "AGB Vermietung" })).toBeVisible();
});
test("should NOT contain 'Neu in der Vermietung'", async ({ page }) => {
const footer = page.locator("footer");
await expect(footer).not.toContainText("Neu in der Vermietung");
});
test("should NOT contain 'AGB Shop'", async ({ page }) => {
const footer = page.locator("footer");
await expect(footer).not.toContainText("AGB Shop");
});
});
+27
View File
@@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
test.describe("Meta and Robots", () => {
test("robots.txt should contain Disallow: /", async ({ request }) => {
const response = await request.get("/robots.txt");
const text = await response.text();
expect(text).toContain("User-agent: *");
expect(text).toContain("Disallow: /");
});
test("home page should have noindex meta tag", async ({ page }) => {
await page.goto("/");
const robotsMeta = page.locator('meta[name="robots"]');
await expect(robotsMeta).toHaveAttribute("content", /noindex/);
await expect(robotsMeta).toHaveAttribute("content", /nofollow/);
await expect(robotsMeta).toHaveAttribute("content", /noarchive/);
await expect(robotsMeta).toHaveAttribute("content", /nosnippet/);
});
test("home page should contain JSON-LD LocalBusiness", async ({ page }) => {
await page.goto("/");
const jsonld = page.locator('script[type="application/ld+json"]');
const content = await jsonld.textContent();
expect(content).toContain("LocalBusiness");
expect(content).toContain("Hammerschmidt");
});
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
describe("Design Tokens", () => {
const cssPath = resolve(__dirname, "../../assets/css/main.css");
const cssContent = readFileSync(cssPath, "utf-8");
it("should define --bg variable in :root", () => {
expect(cssContent).toContain("--bg: #131313");
});
it("should define --panel variable in :root", () => {
expect(cssContent).toContain("--panel: #1a1a1a");
});
it("should define --color-accent variable in :root", () => {
expect(cssContent).toContain("--color-accent: #EC6925");
});
it("should define --surface variable in :root", () => {
expect(cssContent).toContain("--surface: #212121");
});
it("should define --card variable in :root", () => {
expect(cssContent).toContain("--card: #2d2d2d");
});
it("should define --border variable in :root", () => {
expect(cssContent).toContain("--border: #444444a8");
});
it("should define --text variable in :root", () => {
expect(cssContent).toContain("--text: #ffffff");
});
it("should define --text-muted variable in :root", () => {
expect(cssContent).toContain("--text-muted: #d4d4d4");
});
it("should define radius tokens", () => {
expect(cssContent).toContain("--radius-sm: 2px");
expect(cssContent).toContain("--radius-md: 3px");
expect(cssContent).toContain("--radius-lg: 4px");
});
it("should define transition tokens", () => {
expect(cssContent).toContain("--transition-fast: 150ms ease");
});
});
describe("Tailwind Config", () => {
const configPath = resolve(__dirname, "../../tailwind.config.ts");
const configContent = readFileSync(configPath, "utf-8");
it("should extend colors with accent", () => {
expect(configContent).toContain("accent");
expect(configContent).toContain("#EC6925");
});
it("should extend colors with bg", () => {
expect(configContent).toContain('bg: "#131313"');
});
it("should extend colors with panel", () => {
expect(configContent).toContain('panel: "#1a1a1a"');
});
it("should configure Inter font family", () => {
expect(configContent).toContain("Inter");
});
});
describe("Nuxt Config", () => {
const configPath = resolve(__dirname, "../../nuxt.config.ts");
const configContent = readFileSync(configPath, "utf-8");
it("should include noindex robots meta tag", () => {
expect(configContent).toContain("noindex");
expect(configContent).toContain("nofollow");
expect(configContent).toContain("noarchive");
expect(configContent).toContain("nosnippet");
});
it("should include JSON-LD LocalBusiness script", () => {
expect(configContent).toContain("LocalBusiness");
expect(configContent).toContain("schema.org");
});
it("should include routeRules for SSR/CSR", () => {
expect(configContent).toContain("routeRules");
expect(configContent).toContain("/mietkatalog");
expect(configContent).toContain("/admin");
});
it("should include Tailwind CSS module", () => {
expect(configContent).toContain("@nuxtjs/tailwindcss");
});
});
+136
View File
@@ -0,0 +1,136 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
describe("AppHeader Component", () => {
const componentPath = resolve(__dirname, "../../components/AppHeader.vue");
const content = readFileSync(componentPath, "utf-8");
it("should contain sticky positioning", () => {
expect(content).toContain("sticky");
expect(content).toContain("top-0");
});
it("should have nav items: Home, Referenzen, Mietkatalog, Kontakt", () => {
expect(content).toContain("Home");
expect(content).toContain("Referenzen");
expect(content).toContain("Mietkatalog");
expect(content).toContain("Kontakt");
});
it("should include phone link tel:+491726264796", () => {
expect(content).toContain("tel:+491726264796");
});
it("should include Facebook social icon link", () => {
expect(content).toContain("facebook.com");
});
it("should include Instagram social icon link", () => {
expect(content).toContain("instagram.com");
});
it("should have mobile burger menu with aria-expanded", () => {
expect(content).toContain("aria-expanded");
expect(content).toContain("mobileMenuOpen");
});
it("should have min touch target height for nav items", () => {
expect(content).toContain("min-h-44px");
});
});
describe("AppFooter Component", () => {
const componentPath = resolve(__dirname, "../../components/AppFooter.vue");
const content = readFileSync(componentPath, "utf-8");
it("should contain address with Leipheim", () => {
expect(content).toContain("89340");
expect(content).toContain("Leipheim");
});
it("should contain phone number +49 8221 204433", () => {
expect(content).toContain("tel:+498221204433");
expect(content).toContain("204433");
});
it("should contain email link info@hms-licht-ton.de", () => {
expect(content).toContain("mailto:info@hms-licht-ton.de");
});
it("should contain Impressum link", () => {
expect(content).toContain("Impressum");
expect(content).toContain("/impressum");
});
it("should contain DSGVO link", () => {
expect(content).toContain("DSGVO");
expect(content).toContain("/datenschutz");
});
it("should contain AGB Vermietung link", () => {
expect(content).toContain("AGB Vermietung");
expect(content).toContain("/agb-vermietung");
});
it("should NOT contain 'Neu in der Vermietung'", () => {
expect(content).not.toContain("Neu in der Vermietung");
});
it("should NOT contain 'AGB Shop'", () => {
expect(content).not.toContain("AGB Shop");
});
it("should have 4-column grid layout", () => {
expect(content).toContain("lg:grid-cols-4");
});
});
describe("Error Page (404)", () => {
const errorPath = resolve(__dirname, "../../error.vue");
const content = readFileSync(errorPath, "utf-8");
it("should contain 'Seite nicht gefunden' for 404", () => {
expect(content).toContain("Seite nicht gefunden");
});
it("should contain link to home /", () => {
expect(content).toContain('to="/"');
});
it("should include noindex meta tag", () => {
expect(content).toContain("noindex");
expect(content).toContain("nofollow");
});
});
describe("HmsLogo Component", () => {
const logoPath = resolve(__dirname, "../../components/HmsLogo.vue");
const content = readFileSync(logoPath, "utf-8");
it("should render SVG element", () => {
expect(content).toContain("<svg");
});
it("should use orange accent color #EC6925", () => {
expect(content).toContain("#EC6925");
});
it("should contain border/stroke with accent color", () => {
expect(content).toContain("accentColor");
expect(content).toContain("stroke");
});
});
describe("robots.txt route", () => {
const robotsPath = resolve(__dirname, "../../server/routes/robots.txt.ts");
const content = readFileSync(robotsPath, "utf-8");
it("should contain User-agent: *", () => {
expect(content).toContain("User-agent: *");
});
it("should contain Disallow: /", () => {
expect(content).toContain("Disallow: /");
});
});
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
export default defineConfig({
test: {
environment: "happy-dom",
globals: true,
include: ["tests/unit/**/*.test.ts"],
},
resolve: {
alias: {
"~": fileURLToPath(new URL("./", import.meta.url)),
"#app": fileURLToPath(new URL("./node_modules/nuxt/dist/app", import.meta.url)),
},
},
});