feat: Implement initial website structure with core pages, layout, and reusable UI components, alongside ESLint configuration and SEO setup.

This commit is contained in:
1elle1
2026-01-30 14:45:52 +01:00
parent f0e917ef5d
commit f1cb4ef2cc
20 changed files with 6800 additions and 19 deletions

View File

@@ -0,0 +1,97 @@
import Link from "next/link";
import { Mail, Phone, MapPin } from "lucide-react";
const NAV_LINKS = [
{ href: "/", label: "Startseite" },
{ href: "/über-uns", label: "Über Uns" },
{ href: "/leistungen", label: "Leistungen" },
{ href: "/studio", label: "Studio" },
{ href: "/aktuelles", label: "Aktuelles" },
] as const;
export function Footer() {
return (
<footer className="bg-primary text-secondary" role="contentinfo">
<div
className="mx-auto px-[var(--spacing-container-padding)] py-16"
style={{ maxWidth: "var(--spacing-container)" }}
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
<div>
<p className="text-xl font-bold tracking-tight mb-4">SPORTBOX</p>
<p className="text-sm text-secondary/70 leading-relaxed">
Ihr Fitnessstudio in Reutte für individuelle Trainingspläne
und persönliche Betreuung.
</p>
</div>
<div>
<p className="text-sm font-bold uppercase tracking-widest mb-4">
Navigation
</p>
<nav aria-label="Footer-Navigation">
<ul className="space-y-2">
{NAV_LINKS.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-sm text-secondary/70 transition-colors hover:text-secondary"
>
{link.label}
</Link>
</li>
))}
</ul>
</nav>
</div>
<div>
<p className="text-sm font-bold uppercase tracking-widest mb-4">
Kontakt
</p>
<address className="not-italic space-y-3">
<a
href="mailto:kontakt@sportbox-reutte.at"
className="flex items-center gap-2 text-sm text-secondary/70 transition-colors hover:text-secondary"
>
<Mail size={16} aria-hidden="true" />
kontakt@sportbox-reutte.at
</a>
<a
href="tel:+43123456789"
className="flex items-center gap-2 text-sm text-secondary/70 transition-colors hover:text-secondary"
>
<Phone size={16} aria-hidden="true" />
+43 123 456 789
</a>
<p className="flex items-center gap-2 text-sm text-secondary/70">
<MapPin size={16} aria-hidden="true" />
Reutte, Tirol, Österreich
</p>
</address>
</div>
</div>
<div className="mt-12 pt-8 border-t border-secondary/20 flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-xs text-secondary/50">
&copy; {new Date().getFullYear()} Sportbox Reutte. Alle Rechte vorbehalten.
</p>
<div className="flex items-center gap-6">
<Link
href="/impressum"
className="text-xs text-secondary/50 transition-colors hover:text-secondary"
>
Impressum
</Link>
<Link
href="/datenschutz"
className="text-xs text-secondary/50 transition-colors hover:text-secondary"
>
Datenschutz
</Link>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Menu, X } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
const NAV_ITEMS = [
{ href: "/", label: "Startseite" },
{ href: "/über-uns", label: "Über Uns" },
{ href: "/leistungen", label: "Leistungen" },
{ href: "/studio", label: "Studio" },
{ href: "/aktuelles", label: "Aktuelles" },
] as const;
export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const pathname = usePathname();
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-background/95 backdrop-blur-sm border-b border-border">
<div className="mx-auto flex items-center justify-between px-[var(--spacing-container-padding)] py-4" style={{ maxWidth: "var(--spacing-container)" }}>
<Link href="/" className="text-xl font-bold tracking-tight" aria-label="Sportbox Reutte Zur Startseite">
SPORTBOX
</Link>
<nav className="hidden lg:flex items-center gap-8" aria-label="Hauptnavigation">
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className={`text-sm tracking-wide transition-colors hover:text-muted ${
pathname === item.href ? "font-bold" : "font-normal"
}`}
>
{item.label}
</Link>
))}
<Link
href="/leistungen#kontakt"
className="bg-primary text-secondary px-5 py-2.5 text-sm font-medium transition-opacity hover:opacity-80"
>
Jetzt Termin buchen
</Link>
</nav>
<button
type="button"
className="lg:hidden p-2 -mr-2"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-expanded={mobileMenuOpen}
aria-controls="mobile-menu"
aria-label={mobileMenuOpen ? "Menü schließen" : "Menü öffnen"}
>
{mobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
<AnimatePresence>
{mobileMenuOpen && (
<motion.nav
id="mobile-menu"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="lg:hidden overflow-hidden border-t border-border bg-background"
aria-label="Mobile Navigation"
>
<div className="flex flex-col px-[var(--spacing-container-padding)] py-4 gap-1">
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileMenuOpen(false)}
className={`py-3 text-base transition-colors hover:text-muted ${
pathname === item.href ? "font-bold" : "font-normal"
}`}
>
{item.label}
</Link>
))}
<Link
href="/leistungen#kontakt"
onClick={() => setMobileMenuOpen(false)}
className="mt-2 bg-primary text-secondary px-5 py-3 text-center text-sm font-medium transition-opacity hover:opacity-80"
>
Jetzt Termin buchen
</Link>
</div>
</motion.nav>
)}
</AnimatePresence>
</header>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
const FAQ_ITEMS = [
{
question: "Brauche ich Vorkenntnisse, um bei Sportbox zu trainieren?",
answer:
"Nein, bei uns sind alle willkommen vom absoluten Anfänger bis zum erfahrenen Sportler. Unsere Trainer erstellen individuelle Trainingspläne und begleiten Sie in Ihrem Tempo.",
},
{
question: "Bieten Sie ein Probetraining an?",
answer:
"Ja, wir bieten ein unverbindliches Probetraining an. Kontaktieren Sie uns einfach über das Kontaktformular oder telefonisch, um einen Termin zu vereinbaren.",
},
{
question: "Welche Öffnungszeiten haben Sie?",
answer:
"Wir haben großzügige Öffnungszeiten, damit Fitness in Ihren Alltag passt. Die aktuellen Öffnungszeiten erfahren Sie bei uns vor Ort oder telefonisch.",
},
{
question: "Welche Kurse bieten Sie an?",
answer:
"Unser Kursangebot umfasst unter anderem Yoga, Pilates, HIIT, Functional Training und Krafttraining. Die Kurse sind für verschiedene Fitnesslevel geeignet. Schauen Sie sich unsere Leistungen an oder fragen Sie direkt bei uns an.",
},
{
question: "Kann ich meinen Trainingsplan anpassen lassen?",
answer:
"Selbstverständlich. Wir passen Ihren Trainingsplan regelmäßig an Ihre Fortschritte und sich verändernde Ziele an. Sprechen Sie einfach Ihren Trainer an.",
},
{
question: "Bieten Sie auch Online-Training an?",
answer:
"Wir arbeiten daran, unser Angebot um Online-Trainingsmöglichkeiten zu erweitern. Kontaktieren Sie uns für aktuelle Informationen.",
},
] as const;
export function FaqSection() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
function toggle(index: number) {
setOpenIndex(openIndex === index ? null : index);
}
return (
<div className="max-w-3xl mx-auto space-y-2">
{FAQ_ITEMS.map((item, index) => (
<div
key={index}
className="border border-border bg-background"
style={{ borderRadius: "var(--radius-md)" }}
>
<button
type="button"
onClick={() => toggle(index)}
aria-expanded={openIndex === index}
className="w-full flex items-center justify-between px-6 py-4 text-left text-sm font-medium"
>
<span>{item.question}</span>
<ChevronDown
size={18}
className={`shrink-0 ml-4 transition-transform duration-200 ${
openIndex === index ? "rotate-180" : ""
}`}
aria-hidden="true"
/>
</button>
{openIndex === index && (
<div className="px-6 pb-4">
<p className="text-sm text-muted leading-relaxed">
{item.answer}
</p>
</div>
)}
</div>
))}
{/* FAQ Schema Markup */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: FAQ_ITEMS.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: {
"@type": "Answer",
text: item.answer,
},
})),
}),
}}
/>
</div>
);
}

45
components/ui/Button.tsx Normal file
View File

@@ -0,0 +1,45 @@
import Link from "next/link";
interface ButtonProps {
children: React.ReactNode;
href?: string;
variant?: "primary" | "secondary" | "ghost";
className?: string;
type?: "button" | "submit" | "reset";
onClick?: () => void;
}
export function Button({
children,
href,
variant = "primary",
className = "",
type = "button",
onClick,
}: ButtonProps) {
const base =
"inline-flex items-center justify-center px-6 py-3 text-sm font-medium transition-all duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary";
const variants = {
primary: "bg-primary text-secondary hover:opacity-80",
secondary:
"border border-primary text-primary hover:bg-primary hover:text-secondary",
ghost: "text-primary underline-offset-4 hover:underline",
};
const classes = `${base} ${variants[variant]} ${className}`;
if (href) {
return (
<Link href={href} className={classes}>
{children}
</Link>
);
}
return (
<button type={type} className={classes} onClick={onClick}>
{children}
</button>
);
}

15
components/ui/Card.tsx Normal file
View File

@@ -0,0 +1,15 @@
interface CardProps {
children: React.ReactNode;
className?: string;
}
export function Card({ children, className = "" }: CardProps) {
return (
<div
className={`border border-border p-6 md:p-8 transition-shadow duration-200 hover:shadow-lg ${className}`}
style={{ borderRadius: "var(--radius-md)" }}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { Button } from "./Button";
interface FormState {
name: string;
email: string;
phone: string;
message: string;
}
interface FormErrors {
name?: string;
email?: string;
message?: string;
}
export function ContactForm() {
const [form, setForm] = useState<FormState>({
name: "",
email: "",
phone: "",
message: "",
});
const [errors, setErrors] = useState<FormErrors>({});
const [submitted, setSubmitted] = useState(false);
function validate(): FormErrors {
const newErrors: FormErrors = {};
if (!form.name.trim()) newErrors.name = "Bitte geben Sie Ihren Namen ein.";
if (!form.email.trim()) {
newErrors.email = "Bitte geben Sie Ihre E-Mail-Adresse ein.";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
newErrors.email = "Bitte geben Sie eine gültige E-Mail-Adresse ein.";
}
if (!form.message.trim())
newErrors.message = "Bitte geben Sie eine Nachricht ein.";
return newErrors;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const newErrors = validate();
setErrors(newErrors);
if (Object.keys(newErrors).length === 0) {
setSubmitted(true);
}
}
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
if (errors[name as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [name]: undefined }));
}
}
if (submitted) {
return (
<div
className="border border-success p-8 text-center"
style={{ borderRadius: "var(--radius-md)" }}
role="status"
aria-live="polite"
>
<p className="text-lg font-bold mb-2">Vielen Dank für Ihre Nachricht!</p>
<p className="text-muted">
Wir werden uns so schnell wie möglich bei Ihnen melden.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} noValidate className="space-y-6">
<div>
<label htmlFor="contact-name" className="block text-sm font-medium mb-1.5">
Name <span aria-hidden="true">*</span>
</label>
<input
id="contact-name"
name="name"
type="text"
autoComplete="name"
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "name-error" : undefined}
value={form.name}
onChange={handleChange}
className="w-full border border-border px-4 py-3 text-sm bg-background text-foreground transition-colors focus:border-primary focus:outline-none"
style={{ borderRadius: "var(--radius-sm)" }}
/>
{errors.name && (
<p id="name-error" className="mt-1.5 text-sm text-error" role="alert">
{errors.name}
</p>
)}
</div>
<div>
<label htmlFor="contact-email" className="block text-sm font-medium mb-1.5">
E-Mail <span aria-hidden="true">*</span>
</label>
<input
id="contact-email"
name="email"
type="email"
autoComplete="email"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
value={form.email}
onChange={handleChange}
className="w-full border border-border px-4 py-3 text-sm bg-background text-foreground transition-colors focus:border-primary focus:outline-none"
style={{ borderRadius: "var(--radius-sm)" }}
/>
{errors.email && (
<p id="email-error" className="mt-1.5 text-sm text-error" role="alert">
{errors.email}
</p>
)}
</div>
<div>
<label htmlFor="contact-phone" className="block text-sm font-medium mb-1.5">
Telefon
</label>
<input
id="contact-phone"
name="phone"
type="tel"
autoComplete="tel"
value={form.phone}
onChange={handleChange}
className="w-full border border-border px-4 py-3 text-sm bg-background text-foreground transition-colors focus:border-primary focus:outline-none"
style={{ borderRadius: "var(--radius-sm)" }}
/>
</div>
<div>
<label htmlFor="contact-message" className="block text-sm font-medium mb-1.5">
Nachricht <span aria-hidden="true">*</span>
</label>
<textarea
id="contact-message"
name="message"
rows={5}
aria-required="true"
aria-invalid={!!errors.message}
aria-describedby={errors.message ? "message-error" : undefined}
value={form.message}
onChange={handleChange}
className="w-full border border-border px-4 py-3 text-sm bg-background text-foreground transition-colors focus:border-primary focus:outline-none resize-y"
style={{ borderRadius: "var(--radius-sm)" }}
/>
{errors.message && (
<p id="message-error" className="mt-1.5 text-sm text-error" role="alert">
{errors.message}
</p>
)}
</div>
<Button type="submit" variant="primary">
Nachricht senden
</Button>
</form>
);
}

View File

@@ -0,0 +1,20 @@
interface ContainerProps {
children: React.ReactNode;
className?: string;
as?: "div" | "section" | "article";
}
export function Container({
children,
className = "",
as: Component = "div",
}: ContainerProps) {
return (
<Component
className={`mx-auto px-[var(--spacing-container-padding)] ${className}`}
style={{ maxWidth: "var(--spacing-container)" }}
>
{children}
</Component>
);
}

23
components/ui/FadeIn.tsx Normal file
View File

@@ -0,0 +1,23 @@
"use client";
import { motion } from "framer-motion";
interface FadeInProps {
children: React.ReactNode;
className?: string;
delay?: number;
}
export function FadeIn({ children, className = "", delay = 0 }: FadeInProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, delay, ease: "easeOut" }}
className={className}
>
{children}
</motion.div>
);
}

View File

@@ -0,0 +1,31 @@
interface SectionHeadingProps {
title: string;
subtitle?: string;
align?: "left" | "center";
}
export function SectionHeading({
title,
subtitle,
align = "center",
}: SectionHeadingProps) {
return (
<div className={`mb-12 ${align === "center" ? "text-center" : "text-left"}`}>
<h2
className="text-3xl md:text-4xl font-bold tracking-tight"
style={{
fontSize: "var(--text-3xl)",
lineHeight: "var(--text-3xl-line-height)",
letterSpacing: "var(--text-3xl-letter-spacing)",
}}
>
{title}
</h2>
{subtitle && (
<p className="mt-4 text-muted max-w-2xl mx-auto" style={{ fontSize: "var(--text-lg)" }}>
{subtitle}
</p>
)}
</div>
);
}