feat: Implement initial website structure with core pages, layout, and reusable UI components, alongside ESLint configuration and SEO setup.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user