added full design

This commit is contained in:
Rajpreet0 2026-09-25 00:45:33 +02:00
parent c641cfed7e
commit b2b4a0b076
67 changed files with 4884 additions and 153 deletions

BIN
app/apple-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

21
app/fonts.ts Normal file
View file

@ -0,0 +1,21 @@
import { JetBrains_Mono, Mona_Sans } from "next/font/google";
// next/font downloads the files at build time and serves them from our own
// domain, so no request ever reaches Google (no extra roundtrip, GDPR-safe).
export const sans = Mona_Sans({
subsets: ["latin", "latin-ext"],
// The width axis is the main typographic lever (see DESIGN.md §3)
axes: ["wdth"],
variable: "--font-mona",
display: "swap",
preload: true,
});
export const mono = JetBrains_Mono({
subsets: ["latin", "latin-ext"],
variable: "--font-jetbrains",
display: "swap",
// Only needed for small labels, not for the LCP element
preload: false,
});

View file

@ -2,129 +2,474 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
/* ── Design tokens ─────────────────────────────────────────────────────── */
@theme {
/* Ink scale: luminance only, no hue */
--color-ink-0: oklch(1 0 0);
--color-ink-50: oklch(0.975 0 0);
--color-ink-100: oklch(0.945 0 0);
--color-ink-200: oklch(0.885 0 0);
--color-ink-300: oklch(0.785 0 0);
--color-ink-400: oklch(0.665 0 0);
--color-ink-500: oklch(0.555 0 0);
--color-ink-600: oklch(0.45 0 0);
--color-ink-700: oklch(0.35 0 0);
--color-ink-800: oklch(0.245 0 0);
--color-ink-900: oklch(0.155 0 0);
--color-ink-950: oklch(0.085 0 0);
--color-ink-1000: oklch(0 0 0);
--color-signal-error: oklch(0.63 0.21 25);
--color-signal-success: oklch(0.72 0.17 150);
/* Type scale */
--text-mega: clamp(3.25rem, 12.5vw, 14rem);
--text-mega--line-height: 0.84;
--text-display: clamp(2.75rem, 8.5vw, 8.5rem);
--text-display--line-height: 0.86;
--text-statement: clamp(2rem, 5vw, 4.75rem);
--text-statement--line-height: 1;
--text-title: clamp(1.5rem, 2.6vw, 2.5rem);
--text-title--line-height: 1.1;
--text-lead: clamp(1.125rem, 1.5vw, 1.375rem);
--text-lead--line-height: 1.5;
--text-body: 1.0625rem;
--text-body--line-height: 1.6;
--text-label: 0.8125rem;
--text-label--line-height: 1.4;
/* Space and layout */
--spacing-section: clamp(7rem, 16vh, 14rem);
--spacing-gutter: clamp(1.25rem, 4vw, 4rem);
--container-site: 90rem;
--radius-glass: 1.5rem;
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out-quart: cubic-bezier(0.76, 0, 0.24, 1);
}
@theme inline {
--font-sans: var(--font-mona), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-jetbrains), ui-monospace, monospace;
--font-heading: var(--font-sans);
--color-bg: var(--bg);
--color-fg: var(--fg);
--color-fg-muted: var(--fg-muted);
--color-fg-subtle: var(--fg-subtle);
--color-line: var(--line);
--color-surface: var(--surface);
/* shadcn/ui bridge */
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/* ── Themes ────────────────────────────────────────────────────────────── */
:root,
[data-theme="light"] {
--bg: var(--color-ink-0);
--fg: var(--color-ink-1000);
--fg-muted: var(--color-ink-600);
--fg-subtle: var(--color-ink-400);
--line: var(--color-ink-200);
--surface: var(--color-ink-100);
}
[data-theme="dark"] {
--bg: var(--color-ink-1000);
--fg: var(--color-ink-0);
--fg-muted: var(--color-ink-400);
--fg-subtle: var(--color-ink-600);
--line: var(--color-ink-800);
--surface: var(--color-ink-900);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--radius: 0.75rem;
/* Static light position for the glass edge highlight */
--light-x: 30%;
--light-y: 0%;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
/*
* Custom properties resolve where they are declared, so the shadcn mapping
* is re-declared on every themed element.
*/
:root,
[data-theme] {
--background: var(--bg);
--foreground: var(--fg);
--card: var(--bg);
--card-foreground: var(--fg);
--popover: var(--bg);
--popover-foreground: var(--fg);
--primary: var(--fg);
--primary-foreground: var(--bg);
--secondary: var(--surface);
--secondary-foreground: var(--fg);
--muted: var(--surface);
--muted-foreground: var(--fg-muted);
--accent: var(--surface);
--accent-foreground: var(--fg);
--destructive: var(--color-signal-error);
--border: var(--line);
--input: var(--color-ink-500);
--ring: var(--fg);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply border-border;
}
html {
@apply font-sans;
scroll-padding-top: 6rem;
}
}
body {
@apply bg-background text-foreground;
font-size: var(--text-body);
line-height: 1.6;
}
[data-theme] {
background-color: var(--bg);
color: var(--fg);
}
::selection {
background: var(--fg);
color: var(--bg);
}
}
:where(a, button, input, textarea, select, summary, [tabindex]):focus-visible {
outline: 2px solid var(--fg);
outline-offset: 3px;
box-shadow: 0 0 0 5px var(--bg);
}
/* ── Smooth scroll (Lenis) ────────────────────────────────────────────── */
html.lenis,
html.lenis body {
height: auto;
}
.lenis:not(.lenis-autoToggle).lenis-stopped {
overflow: clip;
}
.lenis [data-lenis-prevent] {
overscroll-behavior: contain;
}
.lenis.lenis-smooth iframe {
pointer-events: none;
}
/* ── Layout & type utilities ──────────────────────────────────────────── */
@utility container-site {
width: 100%;
max-width: var(--container-site);
margin-inline: auto;
padding-inline: var(--spacing-gutter);
}
/* Poster type: heavy, condensed, uppercase */
@utility type-poster {
font-variation-settings: "wght" 800, "wdth" 75;
text-transform: uppercase;
letter-spacing: -0.01em;
text-wrap: balance;
}
@utility type-statement {
font-size: var(--text-statement);
font-variation-settings: "wght" 560, "wdth" 90;
line-height: 1;
letter-spacing: -0.025em;
text-wrap: balance;
}
@utility type-title {
font-size: var(--text-title);
font-variation-settings: "wght" 600, "wdth" 90;
line-height: 1.1;
letter-spacing: -0.02em;
text-wrap: balance;
}
@utility type-body {
line-height: 1.6;
max-width: 62ch;
text-wrap: pretty;
}
@utility type-label {
font-family: var(--font-mono);
font-size: var(--text-label);
color: var(--fg-muted);
}
/*
* Outlined type. Variable fonts have overlapping contours that a plain stroke
* would draw inside the letters, so the stroke is painted first at double
* width and the fill (in the background color) covers its inner half.
*/
@utility text-outline {
color: var(--bg);
-webkit-text-stroke: max(2px, 0.024em) var(--stroke-color, var(--fg));
paint-order: stroke fill;
}
/* ── Grain over everything ────────────────────────────────────────────── */
body::after {
content: "";
position: fixed;
inset: -50%;
z-index: 90;
pointer-events: none;
opacity: 0.06;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 1.2 -0.1'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* ── Glass ────────────────────────────────────────────────────────────── */
.glass {
position: relative;
isolation: isolate;
background: linear-gradient(
135deg,
rgb(255 255 255 / 0.08),
rgb(255 255 255 / 0.02)
);
backdrop-filter: blur(24px) saturate(1.2);
border: 1px solid rgb(255 255 255 / 0.12);
border-radius: var(--radius-glass);
box-shadow:
0 1px 0 0 rgb(255 255 255 / 0.14) inset,
0 30px 80px -30px rgb(0 0 0 / 0.6);
}
.glass::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(
120% 90% at var(--light-x) var(--light-y),
rgb(255 255 255 / 0.1),
transparent 60%
);
}
@media (prefers-reduced-transparency: reduce) {
.glass {
backdrop-filter: none;
background: var(--color-ink-900);
}
}
/* ── Intro gating ─────────────────────────────────────────────────────── */
/*
* An inline script adds `is-intro` before the first paint when the preloader
* should play. Everything that animates in after the preloader is paused while
* that class is present. Without JavaScript the class never appears and the
* page simply animates in.
*/
.preloader {
display: none;
clip-path: inset(0 0 0 0);
transition: clip-path 1000ms var(--ease-in-out-quart);
}
html.is-intro .preloader,
.preloader[data-state="leaving"] {
display: flex;
}
.preloader[data-state="leaving"] {
clip-path: inset(0 0 100% 0);
}
html.is-intro {
overflow: hidden;
}
html.is-intro :is(.hero-char, .hero-fade) {
animation-play-state: paused;
}
@keyframes char-in {
from {
translate: 0 105%;
rotate: 6deg;
}
to {
translate: 0 0;
rotate: 0deg;
}
}
@keyframes fade-up {
from {
opacity: 0;
translate: 0 1.5rem;
}
}
.hero-char {
display: inline-block;
transform-origin: 0 100%;
animation: char-in 1100ms var(--ease-out-expo) calc(150ms + var(--i) * 28ms) both;
}
.hero-fade {
animation: fade-up 1200ms var(--ease-out-expo) calc(700ms + var(--d, 0ms)) both;
}
/* ── Header: inverts itself over light and dark sections ──────────────── */
.site-header {
mix-blend-mode: difference;
color: white;
transition: translate 500ms var(--ease-in-out-quart);
}
.site-header[data-hidden="true"] {
translate: 0 -120%;
}
/* ── Sound toggle equalizer ───────────────────────────────────────────── */
@keyframes equalizer {
0%,
100% {
scale: 1 0.25;
}
50% {
scale: 1 1;
}
}
@utility animate-equalizer {
animation: equalizer 0.9s ease-in-out infinite;
}
/* ── Custom cursor ────────────────────────────────────────────────────── */
@media (hover: hover) and (pointer: fine) {
html.has-cursor,
html.has-cursor * {
cursor: none !important;
}
html.has-cursor :is(input, textarea, select) {
cursor: text !important;
}
}
/* ── Services: rows wipe to inverted on hover ─────────────────────────── */
.service-row {
position: relative;
isolation: isolate;
}
.service-row::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: var(--fg);
scale: 1 0;
transform-origin: 50% 100%;
transition: scale 600ms var(--ease-in-out-quart);
}
.service-row :is(.service-name, .service-meta) {
transition:
color 400ms var(--ease-in-out-quart),
translate 600ms var(--ease-in-out-quart);
}
.service-row .service-details {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 600ms var(--ease-in-out-quart);
}
@media (hover: hover) and (pointer: fine) {
.service-row:hover::before,
.service-row:focus-within::before {
scale: 1 1;
transform-origin: 50% 0%;
}
.service-row:is(:hover, :focus-within) :is(.service-name, .service-meta) {
color: var(--bg);
}
.service-row:is(:hover, :focus-within) .service-name {
translate: 1.5rem 0;
}
.service-row:is(:hover, :focus-within) .service-details {
grid-template-rows: 1fr;
}
}
/* Touch devices: details are always visible */
@media not ((hover: hover) and (pointer: fine)) {
.service-row .service-details {
grid-template-rows: 1fr;
}
}
/* ── Scroll-driven reveal (progressive enhancement) ──────────────────── */
@supports (animation-timeline: view()) {
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 5% cover 30%;
}
.clip-reveal {
animation: clip-reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
@keyframes clip-reveal {
from {
clip-path: inset(18% 8% 18% 8% round 3rem);
}
to {
clip-path: inset(0 0 0 0 round var(--radius-glass));
}
}
}
/* ── Reduced motion ───────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-delay: 0ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.reveal,
.clip-reveal {
animation: none !important;
}
}

21
app/icon.svg Normal file
View file

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="chrome" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="0.55" stop-color="#f2f2f2"/>
<stop offset="1" stop-color="#b8b8b8"/>
</linearGradient>
<radialGradient id="light" cx="0.78" cy="0.12" r="0.75">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.2"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="512" height="512" rx="112" fill="#000"/>
<rect width="512" height="512" rx="112" fill="url(#light)"/>
<rect x="4" y="4" width="504" height="504" rx="108" fill="none" stroke="#fff" stroke-opacity="0.12" stroke-width="8"/>
<g transform="translate(256 256) scale(0.8522) translate(-248.2 -168.3)" stroke-width="34" stroke-linejoin="round">
<path d="M172.13,85.97l13.34,21.24-116.23,71.29,116.23,71.94-14.82,20.25-125.12-78.2v-28.32l126.6-78.2Z" transform="translate(-45.5 0)" fill="#7b7b7b" stroke="#7b7b7b"/>
<path d="M1290.29,51.24c14.27,0,26.59,2.55,36.96,7.66s18.41,11.96,24.12,20.58c5.71,8.62,8.56,18.08,8.56,28.4,0,9.11-2.03,17.4-6.09,24.86-4.06,7.46-9.44,13.61-16.13,18.44-6.7,4.83-14.05,8.01-22.06,9.55,8.89.66,17.23,3.16,25.02,7.49,7.79,4.34,14.16,10.48,19.1,18.44,4.94,7.96,7.41,17.81,7.41,29.55,0,13.06-3.29,24.83-9.88,35.31-6.58,10.48-15.78,18.74-27.58,24.78-11.8,6.04-25.55,9.05-41.24,9.05-12.62,0-25.22-2.42-37.78-7.24-12.57-4.83-23.19-12.51-31.86-23.05l18.6-15.15c6.47,7.57,14.18,13.23,23.13,16.96,8.94,3.73,18.03,5.6,27.25,5.6,15.8,0,28.15-4.14,37.04-12.43,8.89-8.29,13.34-19.56,13.34-33.83,0-10.75-2.06-19.26-6.17-25.52-4.12-6.26-9.69-10.73-16.71-13.42-7.03-2.69-15.04-4.03-24.04-4.03h-19.1l3.46-21.9h13.33c7.35,0,14.29-1.4,20.83-4.2,6.53-2.8,11.88-7.16,16.05-13.09,4.17-5.93,6.26-13.34,6.26-22.23,0-12.51-4.17-22.06-12.51-28.65-8.34-6.59-18.44-9.88-30.29-9.88-9.88,0-18.5,1.73-25.85,5.19-7.35,3.46-14.76,8.7-22.22,15.72l-15.31-16.96c9.66-9.11,19.92-15.72,30.79-19.84,10.87-4.12,22.06-6.17,33.58-6.17Z" transform="translate(-1044.8 0)" fill="url(#chrome)" stroke="url(#chrome)"/>
<path d="M1441.75,85.97l126.6,78.2v28.32l-124.96,78.2-14.98-20.41,116.23-71.78-116.23-71.62,13.33-20.91Z" transform="translate(-1072.0 0)" fill="#7b7b7b" stroke="#7b7b7b"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -1,29 +1,69 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import type { Metadata, Viewport } from "next";
import { site } from "@/modules/shared/config/site";
import { introScript } from "@/modules/shared/lib/intro";
import { CustomCursor } from "@/modules/shared/ui/components/custom-cursor";
import { InlineScript } from "@/modules/shared/ui/components/inline-script";
import { Preloader } from "@/modules/shared/ui/components/preloader";
import { SiteFooter } from "@/modules/shared/ui/components/site-footer";
import { SiteHeader } from "@/modules/shared/ui/components/site-header";
import { SmoothScroll } from "@/modules/shared/ui/components/smooth-scroll";
import { SoundEffects } from "@/modules/shared/ui/components/sound-effects";
import { mono, sans } from "./fonts";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
metadataBase: new URL(site.url),
title: {
default: "since3 – Websites und Software aus Steinfurt",
template: "%s – since3",
},
description: site.description,
openGraph: {
type: "website",
locale: "de_DE",
siteName: site.name,
url: site.url,
},
};
export const viewport: Viewport = {
themeColor: "#000000",
colorScheme: "dark",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
// The intro script adds a class before hydration
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
lang="de"
className={`${sans.variable} ${mono.variable} antialiased`}
suppressHydrationWarning
>
<body className="min-h-full flex flex-col">{children}</body>
<head>
<InlineScript html={introScript} />
</head>
<body data-theme="dark" className="overflow-x-clip">
<div id="top" />
<a
href="#inhalt"
className="sr-only z-100 rounded-full bg-white px-5 py-3 text-black focus:not-sr-only focus:fixed focus:top-4 focus:left-4"
>
Zum Inhalt springen
</a>
<Preloader />
<SmoothScroll />
<SoundEffects />
<CustomCursor />
<SiteHeader />
<main id="inhalt" tabIndex={-1} className="outline-none">
{children}
</main>
<SiteFooter />
</body>
</html>
);
}

26
app/manifest.ts Normal file
View file

@ -0,0 +1,26 @@
import type { MetadataRoute } from "next";
import { site } from "@/modules/shared/config/site";
// Used when the site is added to a home screen (Android, desktop PWA install)
export default function manifest(): MetadataRoute.Manifest {
return {
name: "since3 – Digitalagentur aus Steinfurt",
short_name: site.name,
description: site.description,
start_url: "/",
display: "standalone",
background_color: "#000000",
theme_color: "#000000",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
{
src: "/icons/icon-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}

View file

@ -1,8 +1,5 @@
import { LandingPageView } from "@/modules/landingPage/ui/views/landing-page-view";
export default function Home() {
return (
<div>
</div>
);
return <LandingPageView />;
}

View file

@ -1,39 +1,36 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button relative inline-flex shrink-0 items-center justify-center rounded-full border font-medium whitespace-nowrap transition-[background-color,border-color,color,scale] duration-200 ease-out select-none active:not-aria-[haspopup]:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
// Black on light, white on dark
solid:
"border-transparent bg-fg text-bg hover:bg-ink-800 dark:hover:bg-ink-200",
// Hairline only
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
"border-fg/20 bg-transparent text-fg hover:border-fg/60 aria-expanded:border-fg/60",
// Only on top of glass surfaces or dark sections
glass:
"border-white/15 bg-white/10 text-white backdrop-blur-md hover:bg-white/20",
ghost: "border-transparent text-fg hover:bg-fg/5 aria-expanded:bg-fg/5",
link: "rounded-none border-transparent px-0 text-fg underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
default: "h-11 gap-2 px-5 text-[0.9375rem]",
sm: "h-9 gap-1.5 px-4 text-sm",
lg: "h-13 gap-2 px-7 text-base",
icon: "size-10",
"icon-sm": "size-8",
},
},
defaultVariants: {
variant: "default",
variant: "solid",
size: "default",
},
}
@ -41,7 +38,7 @@ const buttonVariants = cva(
function Button({
className,
variant = "default",
variant = "solid",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
@ -54,4 +51,22 @@ function Button({
)
}
export { Button, buttonVariants }
/**
* The arrow is not part of the label: it appears on hover only.
* Place it as the last child of a button or a link styled with `buttonVariants`.
*/
function ButtonArrow({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={cn(
"-ml-2 grid w-0 overflow-hidden opacity-0 transition-[width,margin,opacity] duration-200 ease-out group-hover/button:ml-0 group-hover/button:w-4 group-hover/button:opacity-100 group-focus-visible/button:ml-0 group-focus-visible/button:w-4 group-focus-visible/button:opacity-100",
className
)}
>
<ArrowRight className="size-4" />
</span>
)
}
export { Button, ButtonArrow, buttonVariants }

96
components/ui/card.tsx Normal file
View file

@ -0,0 +1,96 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
/**
* Two strictly separate variants (DESIGN.md §8):
* - `flat`: hairline, no shadow, no blur. For lists.
* - `glass`: tool panels only. Never mix both in one section.
*/
const cardVariants = cva("flex flex-col text-card-foreground", {
variants: {
variant: {
flat: "card-flat rounded-2xl border border-line bg-transparent",
glass: "glass",
},
},
defaultVariants: {
variant: "flat",
},
})
function Card({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof cardVariants>) {
return (
<div
data-slot="card"
data-variant={variant ?? "flat"}
className={cn(cardVariants({ variant }), className)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("flex flex-col gap-2 p-6", className)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
return (
<h3
data-slot="card-title"
className={cn("text-title font-heading", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="card-description"
className={cn("type-body text-fg-muted", className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6 pb-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 pb-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
cardVariants,
}

20
components/ui/input.tsx Normal file
View file

@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-12 w-full min-w-0 rounded-xl border border-input bg-transparent px-4 text-base text-fg transition-colors duration-150 placeholder:text-fg-muted hover:border-fg/70 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-signal-error",
className
)}
{...props}
/>
)
}
export { Input }

18
components/ui/label.tsx Normal file
View file

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 font-mono text-label leading-none text-fg select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-32 w-full rounded-xl border border-input bg-transparent px-4 py-3 text-base text-fg transition-colors duration-150 placeholder:text-fg-muted hover:border-fg/70 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-signal-error",
className
)}
{...props}
/>
)
}
export { Textarea }

161
lib/sound.ts Normal file
View file

@ -0,0 +1,161 @@
// Synthesized UI sounds via the Web Audio API: zero network cost, no files.
// Off by default; the visitor opts in with the toggle in the header.
const STORAGE_KEY = "since3:sound"
export const SOUND_CHANGE_EVENT = "since3:sound-change"
export type SoundVoice = "hover" | "click" | "tick" | "on" | "off" | "confirm" | "whoosh"
// Fallback when localStorage is blocked (private mode, strict cookie settings)
let memoryEnabled = false
export function isEnabled() {
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
return stored === null ? memoryEnabled : stored === "on"
} catch {
return memoryEnabled
}
}
export function setEnabled(enabled: boolean) {
memoryEnabled = enabled
try {
window.localStorage.setItem(STORAGE_KEY, enabled ? "on" : "off")
} catch {
// Storage blocked: the in-memory value keeps working for this visit
}
window.dispatchEvent(new Event(SOUND_CHANGE_EVENT))
}
/* ── Engine ─────────────────────────────────────────────────────────── */
type Engine = { ctx: AudioContext; master: GainNode; noise: AudioBuffer }
let engine: Engine | null = null
// Created lazily on first use, which is always inside a user gesture
function getEngine(): Engine {
if (!engine) {
const ctx = new AudioContext()
// A gentle compressor glues overlapping sounds and prevents clipping
const compressor = ctx.createDynamicsCompressor()
compressor.threshold.value = -16
compressor.ratio.value = 4
const master = ctx.createGain()
master.gain.value = 0.9
master.connect(compressor).connect(ctx.destination)
const noise = ctx.createBuffer(1, ctx.sampleRate / 2, ctx.sampleRate)
const data = noise.getChannelData(0)
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1
engine = { ctx, master, noise }
}
if (engine.ctx.state === "suspended") void engine.ctx.resume()
return engine
}
// Slight random variation so repeated sounds never feel mechanical
function vary(value: number, amount: number) {
return value * (1 + (Math.random() * 2 - 1) * amount)
}
type ToneOptions = {
freq: number
endFreq?: number
type?: OscillatorType
start: number
attack?: number
decay: number
peak: number
}
function tone({ ctx, master }: Engine, options: ToneOptions) {
const { freq, endFreq, type = "sine", start, attack = 0.004, decay, peak } = options
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = type
osc.frequency.setValueAtTime(freq, start)
if (endFreq) osc.frequency.exponentialRampToValueAtTime(endFreq, start + attack + decay)
gain.gain.setValueAtTime(0.0001, start)
gain.gain.exponentialRampToValueAtTime(peak, start + attack)
gain.gain.exponentialRampToValueAtTime(0.0001, start + attack + decay)
osc.connect(gain).connect(master)
osc.start(start)
osc.stop(start + attack + decay + 0.02)
}
type NoiseOptions = {
start: number
attack?: number
decay: number
peak: number
filter: BiquadFilterType
freq: number
endFreq?: number
q?: number
}
function noiseBurst({ ctx, master, noise }: Engine, options: NoiseOptions) {
const { start, attack = 0.002, decay, peak, filter, freq, endFreq, q = 1 } = options
const source = ctx.createBufferSource()
source.buffer = noise
const biquad = ctx.createBiquadFilter()
biquad.type = filter
biquad.Q.value = q
biquad.frequency.setValueAtTime(freq, start)
if (endFreq) biquad.frequency.exponentialRampToValueAtTime(endFreq, start + attack + decay)
const gain = ctx.createGain()
gain.gain.setValueAtTime(0.0001, start)
gain.gain.exponentialRampToValueAtTime(peak, start + attack)
gain.gain.exponentialRampToValueAtTime(0.0001, start + attack + decay)
source.connect(biquad).connect(gain).connect(master)
source.start(start, Math.random() * 0.3)
source.stop(start + attack + decay + 0.02)
}
const VOICES: Record<SoundVoice, (engine: Engine, now: number) => void> = {
// Light glassy tick when the pointer enters a link or button
hover: (e, t) => tone(e, { freq: vary(2400, 0.06), start: t, attack: 0.002, decay: 0.035, peak: 0.07 }),
// Short pitch drop plus a click transient; audible on laptop speakers too
click: (e, t) => {
tone(e, { freq: 900, endFreq: 280, start: t, attack: 0.002, decay: 0.07, peak: 0.4 })
tone(e, { freq: 1800, endFreq: 900, type: "triangle", start: t, attack: 0.001, decay: 0.03, peak: 0.12 })
noiseBurst(e, { start: t, decay: 0.012, peak: 0.2, filter: "highpass", freq: 3500 })
},
// Typewriter tick for each line of tool output
tick: (e, t) =>
noiseBurst(e, { start: t, decay: 0.02, peak: 0.28, filter: "bandpass", freq: vary(4200, 0.15), q: 4 }),
// Sound switched on: two rising notes (a fifth)
on: (e, t) => {
tone(e, { freq: 660, type: "triangle", start: t, decay: 0.18, peak: 0.28 })
tone(e, { freq: 990, type: "triangle", start: t + 0.08, decay: 0.28, peak: 0.28 })
},
// Sound switched off: the same notes falling
off: (e, t) => {
tone(e, { freq: 990, type: "triangle", start: t, decay: 0.14, peak: 0.22 })
tone(e, { freq: 660, type: "triangle", start: t + 0.07, decay: 0.2, peak: 0.18 })
},
// Success: bright major arpeggio
confirm: (e, t) => {
;[1046.5, 1318.5, 1568, 2093].forEach((freq, i) =>
tone(e, { freq, type: "triangle", start: t + i * 0.07, attack: 0.005, decay: 0.45, peak: 0.2 })
)
},
// Menu open/close: filtered noise sweep
whoosh: (e, t) =>
noiseBurst(e, { start: t, attack: 0.12, decay: 0.25, peak: 0.3, filter: "bandpass", freq: 350, endFreq: 3200, q: 0.9 }),
}
export function play(voice: SoundVoice, { force = false } = {}) {
if (typeof window === "undefined") return
if (!force && !isEnabled()) return
const current = getEngine()
VOICES[voice](current, current.ctx.currentTime)
}

View file

@ -1 +1,15 @@
export { cn } from "cn"
import { createCn } from "cn/config"
// Register the custom type scale from globals.css so class merging treats
// `text-mega` etc. as font sizes instead of colors (which would drop one of them).
export const cn = createCn({
extend: {
classGroups: {
"font-size": [
{
text: ["mega", "display", "statement", "title", "lead", "body", "label"],
},
],
},
},
})

View file

@ -0,0 +1,135 @@
// All copy of the landing page in one place, so text changes never touch layout code.
export type Project = {
slug: string
title: string
client: string
year: number
// One sentence about the benefit for the client, not about us
benefit: string
tags: string[]
url?: string
image?: { src: string; alt: string }
}
// TODO: replace with real projects and screenshots (AVIF/WebP in /public/projects).
export const projects: Project[] = [
{
slug: "projekt-eins",
title: "Relaunch einer Unternehmenswebsite",
client: "[Kundenname]",
year: 2026,
benefit:
"[Ein Satz zum messbaren Nutzen, z. B. mehr Anfragen seit dem Launch]",
tags: ["Webentwicklung", "SEO", "Next.js"],
},
{
slug: "projekt-zwei",
title: "Interne Auftragsverwaltung",
client: "[Kundenname]",
year: 2025,
benefit: "[Ein Satz zum messbaren Nutzen, z. B. eingesparte Stunden pro Woche]",
tags: ["Softwareentwicklung", "Dashboard"],
},
{
slug: "projekt-drei",
title: "Automatisierte Angebotserstellung",
client: "[Kundenname]",
year: 2025,
benefit: "[Ein Satz zum messbaren Nutzen, z. B. schnellere Angebote]",
tags: ["KI-Automatisierung"],
},
]
export type Service = {
slug: string
title: string
description: string
tags: string[]
}
export const services: Service[] = [
{
slug: "webentwicklung",
title: "Webentwicklung",
description:
"Schnelle, barrierefreie Websites, die Besucher zu Anfragen machen.",
tags: ["Next.js", "Headless CMS", "BFSG"],
},
{
slug: "softwareentwicklung",
title: "Softwareentwicklung",
description:
"Individuelle Web-Anwendungen für Abläufe, die kein Standardtool abbildet.",
tags: ["Web-Apps", "Schnittstellen", "Dashboards"],
},
{
slug: "seo",
title: "SEO",
description:
"Bessere Rankings, mehr Sichtbarkeit, mehr Kunden. Lokal und bei KI-Suchen.",
tags: ["Technisches SEO", "Local SEO", "GEO"],
},
{
slug: "ki-automatisierung",
title: "KI-Automatisierung",
description:
"Wiederkehrende Arbeit an Software abgeben, damit Menschen Zeit für Kunden haben.",
tags: ["Workflows", "LLM-Integration"],
},
{
slug: "it-betreuung",
title: "IT-Betreuung",
description:
"Hosting, Updates und ein fester Ansprechpartner, wenn etwas nicht läuft.",
tags: ["Hosting", "Wartung", "Support"],
},
]
export const processSteps = [
{
title: "Kostenloses Erstgespräch",
description:
"Wir hören zu, stellen Fragen und sagen ehrlich, ob wir die Richtigen sind.",
},
{
title: "Individuelle Planung",
description:
"Ein schriftliches Konzept mit Umfang, Zeitplan und Festpreis. Keine Überraschungen.",
},
{
title: "Entwicklung",
description:
"Wöchentliche Zwischenstände, die man anklicken kann, statt Statusberichten.",
},
{
title: "Live-Schaltung",
description:
"Launch, Einweisung und Betreuung danach. Wir bleiben erreichbar.",
},
]
export const aboutStatement =
"since3 sind zwei Freunde aus Steinfurt. Wir bauen Websites, Software und Automatisierungen, die schnell laden, für alle funktionieren und jeden Tag Arbeit abnehmen. Kein Agentur-Blabla. Nur Dinge, die funktionieren."
export type Stat = { value: number; label: string; prefix?: string; suffix?: string }
// Commitments, not vanity metrics. Only list what we can back up.
export const stats: Stat[] = [
{ value: 2, label: "Gründer, die selbst entwickeln" },
{ value: 100, label: "Lighthouse-Score als Ziel für jede Website" },
{ value: 48, suffix: "h", label: "bis zur ersten Antwort auf deine Anfrage" },
{ value: 0, label: "Subunternehmer. Alles aus einer Hand." },
]
export const aiPrompt =
"Was macht die Digitalagentur since3 aus Steinfurt und für wen ist sie die richtige Wahl?"
export const aiAssistants = [
{ name: "ChatGPT", href: `https://chatgpt.com/?q=${encodeURIComponent(aiPrompt)}` },
{
name: "Perplexity",
href: `https://www.perplexity.ai/search?q=${encodeURIComponent(aiPrompt)}`,
},
{ name: "Claude", href: `https://claude.ai/new?q=${encodeURIComponent(aiPrompt)}` },
]

View file

@ -0,0 +1,124 @@
// Single source of truth for the price calculator.
//
// TODO: replace the EXAMPLE values below with your real prices, then set
// `isExample` to false. While it is true, the calculator shows a visible
// "Beispielpreise" badge so placeholder prices can never go live unnoticed.
export type VatMode =
// B2B: net prices plus VAT
| "net"
// B2C: prices incl. 19 % VAT (required by the PAngV when addressing consumers)
| "gross"
// Kleinunternehmer according to §19 UStG
| "small-business"
export type ProjectTypeId = "landing" | "website" | "shop" | "software" | "automation"
export type ProjectType = {
id: ProjectTypeId
label: string
description: string
base: number
// Delivery time in weeks at "small"/included scope
weeks: [number, number]
// Website-like projects are sized by page count
pages?: { included: number; max: number; pricePerPage: number }
// Software-like projects are sized by complexity instead
sizedByComplexity?: boolean
}
export type Extra = {
id: string
label: string
price: number
// Project types this extra is offered for
for: ProjectTypeId[]
}
export type MonthlyService = {
id: string
label: string
description: string
price: number
}
export const pricing = {
isExample: true,
vat: "net" as VatMode,
vatRate: 0.19,
// The estimate is shown as a range around the calculated value
spread: { low: 0.9, high: 1.2 },
roundTo: 100,
projectTypes: [
{
id: "landing",
label: "Landingpage",
description: "Eine starke Seite für ein Angebot oder eine Kampagne.",
base: 1500,
weeks: [2, 3],
},
{
id: "website",
label: "Website",
description: "Unternehmensauftritt mit mehreren Seiten.",
base: 3500,
weeks: [4, 6],
pages: { included: 5, max: 30, pricePerPage: 250 },
},
{
id: "shop",
label: "Online-Shop",
description: "Verkaufen mit Warenkorb, Zahlung und Versand.",
base: 6500,
weeks: [6, 10],
pages: { included: 5, max: 30, pricePerPage: 250 },
},
{
id: "software",
label: "Web-App",
description: "Individuelle Software, Portale, interne Tools.",
base: 8000,
weeks: [6, 10],
sizedByComplexity: true,
},
{
id: "automation",
label: "KI-Automatisierung",
description: "Abläufe automatisieren, KI sinnvoll einbinden.",
base: 2500,
weeks: [2, 4],
sizedByComplexity: true,
},
] satisfies ProjectType[],
complexity: [
{ id: "small", label: "Überschaubar", hint: "Ein klarer Ablauf, wenige Rollen", factor: 1 },
{ id: "medium", label: "Mittel", hint: "Mehrere Abläufe, Nutzerkonten", factor: 1.8 },
{ id: "large", label: "Umfangreich", hint: "Viele Schnittstellen und Rollen", factor: 3 },
],
extras: [
{ id: "cms", label: "Inhalte selbst pflegen (CMS)", price: 900, for: ["landing", "website"] },
{ id: "language", label: "Zweite Sprache", price: 900, for: ["landing", "website", "shop", "software"] },
{ id: "copy", label: "Texte schreiben wir", price: 600, for: ["landing", "website", "shop"] },
{ id: "branding", label: "Logo & Markenauftritt", price: 1200, for: ["landing", "website", "shop", "software"] },
{ id: "booking", label: "Terminbuchung", price: 800, for: ["landing", "website"] },
{ id: "seo", label: "SEO-Grundpaket", price: 600, for: ["landing", "website", "shop"] },
{ id: "integrations", label: "Anbindung an bestehende Systeme", price: 1500, for: ["shop", "software", "automation"] },
{ id: "ai", label: "KI-Assistent / Chatbot", price: 1800, for: ["website", "shop", "software", "automation"] },
] satisfies Extra[],
monthly: [
{ id: "care", label: "Hosting & Wartung", description: "Updates, Backups, Sicherheit", price: 49 },
{ id: "seo-care", label: "SEO-Betreuung", description: "Monatliche Optimierung und Reporting", price: 290 },
] satisfies MonthlyService[],
express: { label: "Express", hint: "Etwa 40 % schneller fertig", factor: 1.25, weeksFactor: 0.6 },
// Always part of the price, shown as reassurance
included: ["Barrierefrei nach BFSG", "Responsive Design", "Schnelle Ladezeiten", "DSGVO-konform"],
}
export type ComplexityId = (typeof pricing.complexity)[number]["id"]

View file

@ -0,0 +1,193 @@
"use client"
import { useActionState, useEffect, useId, useRef } from "react"
import { CircleAlert, CircleCheck } from "lucide-react"
import { Button, ButtonArrow } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { play } from "@/lib/sound"
import { cn } from "@/lib/utils"
import { CONTACT_PREFILL_EVENT } from "@/modules/landingPage/lib/contact-prefill"
import {
initialContactState,
type ContactField,
} from "@/modules/landingPage/schema/contact-schema"
import { submitContact } from "@/modules/landingPage/server/contact-action"
export function ContactForm() {
const [state, formAction, pending] = useActionState(
submitContact,
initialContactState
)
const id = useId()
const messageRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (state.status === "success") play("confirm")
}, [state])
// Text handed over by other sections, e.g. the price calculator
useEffect(() => {
const onPrefill = (event: Event) => {
const el = messageRef.current
if (!el) return
const text = (event as CustomEvent<string>).detail
// Keep whatever the visitor already wrote
el.value = el.value.trim() ? `${text}
${el.value}` : text
// The page is still scrolling there, so focus without jumping
el.focus({ preventScroll: true })
el.setSelectionRange(el.value.length, el.value.length)
}
window.addEventListener(CONTACT_PREFILL_EVENT, onPrefill)
return () => window.removeEventListener(CONTACT_PREFILL_EVENT, onPrefill)
}, [])
const fieldId = (field: ContactField) => `${id}-${field}`
const errorId = (field: ContactField) => `${id}-${field}-error`
const errorFor = (field: ContactField) => state.fieldErrors?.[field]?.[0]
// Wires label, error text and aria attributes together for one field
const fieldProps = (field: ContactField) => ({
id: fieldId(field),
name: field,
"aria-invalid": errorFor(field) ? true : undefined,
"aria-describedby": errorFor(field) ? errorId(field) : undefined,
})
if (state.status === "success") {
return (
<div role="status" className="flex flex-col items-start gap-4 py-8">
<CircleCheck className="size-8 text-signal-success" aria-hidden />
<p className="text-title font-heading">{state.message}</p>
<p className="type-body text-fg-muted">
Du bekommst eine Antwort von einem Menschen, nicht von einem Ticketsystem.
</p>
</div>
)
}
return (
<form action={formAction} noValidate className="grid gap-6 sm:grid-cols-2">
<Field id={errorId("name")} message={errorFor("name")}>
<Label htmlFor={fieldId("name")}>Name</Label>
<Input
{...fieldProps("name")}
autoComplete="name"
required
defaultValue={state.values?.name}
/>
</Field>
<Field id={errorId("email")} message={errorFor("email")}>
<Label htmlFor={fieldId("email")}>E-Mail</Label>
<Input
{...fieldProps("email")}
type="email"
autoComplete="email"
required
defaultValue={state.values?.email}
/>
</Field>
<Field
id={errorId("company")}
message={errorFor("company")}
className="sm:col-span-2"
>
<Label htmlFor={fieldId("company")}>
Unternehmen <span className="text-fg-muted">(optional)</span>
</Label>
<Input
{...fieldProps("company")}
autoComplete="organization"
defaultValue={state.values?.company}
/>
</Field>
<Field
id={errorId("message")}
message={errorFor("message")}
className="sm:col-span-2"
>
<Label htmlFor={fieldId("message")}>Worum geht es?</Label>
<Textarea
{...fieldProps("message")}
ref={messageRef}
required
rows={5}
defaultValue={state.values?.message}
/>
</Field>
{/* Honeypot, hidden from people and assistive technology */}
<div aria-hidden="true" className="absolute -left-[9999px] h-px w-px overflow-hidden">
<label>
Website
<input type="text" name="website" tabIndex={-1} autoComplete="off" />
</label>
</div>
<Field
id={errorId("privacy")}
message={errorFor("privacy")}
className="sm:col-span-2"
>
<div className="flex items-start gap-3">
<input
{...fieldProps("privacy")}
type="checkbox"
required
className="mt-0.5 size-5 shrink-0 accent-current"
/>
<label htmlFor={fieldId("privacy")} className="text-sm text-fg-muted">
Ich bin einverstanden, dass meine Angaben zur Bearbeitung der Anfrage
gespeichert werden. Details in der{" "}
<a href="/datenschutz" className="text-fg underline underline-offset-4">
Datenschutzerklärung
</a>
.
</label>
</div>
</Field>
<div className="flex flex-col items-start gap-4 sm:col-span-2 sm:flex-row sm:items-center">
<Button type="submit" size="lg" disabled={pending}>
{pending ? "Wird gesendet …" : "Anfrage senden"}
<ButtonArrow />
</Button>
<p aria-live="polite" className="text-sm text-signal-error">
{state.status === "error" ? state.message : null}
</p>
</div>
</form>
)
}
function Field({
id,
message,
className,
children,
}: {
id: string
message?: string
className?: string
children: React.ReactNode
}) {
return (
<div className={cn("flex flex-col gap-2.5", className)}>
{children}
{/* Errors are shown in text and color, never color alone */}
{message ? (
<p id={id} className="flex items-center gap-1.5 text-sm text-signal-error">
<CircleAlert className="size-4 shrink-0" aria-hidden />
{message}
</p>
) : null}
</div>
)
}

View file

@ -0,0 +1,7 @@
// Lets other sections (e.g. the price calculator) hand text to the contact form.
export const CONTACT_PREFILL_EVENT = "since3:contact-prefill"
export function prefillContact(message: string) {
window.dispatchEvent(new CustomEvent<string>(CONTACT_PREFILL_EVENT, { detail: message }))
}

View file

@ -0,0 +1,152 @@
// Pure pricing logic for the calculator. No React, so it is easy to test.
import {
pricing,
type ComplexityId,
type ProjectType,
type ProjectTypeId,
} from "@/modules/landingPage/data/pricing"
export type CalculatorState = {
type: ProjectTypeId
pages: number
complexity: ComplexityId
extras: string[]
monthly: string[]
express: boolean
}
export type Estimate = {
low: number
high: number
monthly: number
weeks: [number, number]
lines: { label: string; amount: number }[]
}
export const initialCalculatorState: CalculatorState = {
type: "website",
pages: 5,
complexity: "small",
extras: [],
monthly: ["care"],
express: false,
}
export function getProjectType(id: ProjectTypeId): ProjectType {
return pricing.projectTypes.find((type) => type.id === id) ?? pricing.projectTypes[0]
}
export function extrasFor(id: ProjectTypeId) {
return pricing.extras.filter((extra) => (extra.for as ProjectTypeId[]).includes(id))
}
function roundTo(value: number, step: number) {
return Math.round(value / step) * step
}
// Converts net prices into what the visitor should see
export function toDisplayPrice(value: number) {
return pricing.vat === "gross" ? value * (1 + pricing.vatRate) : value
}
export function estimate(state: CalculatorState): Estimate {
const type = getProjectType(state.type)
const lines: Estimate["lines"] = [{ label: type.label, amount: type.base }]
let project = type.base
let weeks: [number, number] = [...type.weeks]
if (type.pages) {
const extraPages = Math.max(0, state.pages - type.pages.included)
if (extraPages > 0) {
const amount = extraPages * type.pages.pricePerPage
project += amount
lines.push({ label: `${extraPages} weitere Seiten`, amount })
// Roughly one more week per ten extra pages
const extraWeeks = Math.ceil(extraPages / 10)
weeks = [weeks[0] + extraWeeks, weeks[1] + extraWeeks]
}
}
if (type.sizedByComplexity) {
const level = pricing.complexity.find((c) => c.id === state.complexity) ?? pricing.complexity[0]
if (level.factor !== 1) {
const amount = project * (level.factor - 1)
project += amount
lines.push({ label: `Umfang: ${level.label}`, amount })
weeks = [Math.round(weeks[0] * level.factor), Math.round(weeks[1] * level.factor)]
}
}
// Only extras that exist for this project type count
for (const extra of extrasFor(state.type)) {
if (state.extras.includes(extra.id)) {
project += extra.price
lines.push({ label: extra.label, amount: extra.price })
}
}
if (state.express) {
const amount = project * (pricing.express.factor - 1)
project += amount
lines.push({ label: "Express-Zuschlag", amount })
weeks = [
Math.max(1, Math.round(weeks[0] * pricing.express.weeksFactor)),
Math.max(1, Math.round(weeks[1] * pricing.express.weeksFactor)),
]
}
const monthly = pricing.monthly
.filter((service) => state.monthly.includes(service.id))
.reduce((sum, service) => sum + service.price, 0)
return {
low: roundTo(toDisplayPrice(project * pricing.spread.low), pricing.roundTo),
high: roundTo(toDisplayPrice(project * pricing.spread.high), pricing.roundTo),
monthly: toDisplayPrice(monthly),
weeks,
lines: lines.map((line) => ({ ...line, amount: toDisplayPrice(line.amount) })),
}
}
const currency = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
})
const plain = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 })
export function formatEuro(value: number) {
return currency.format(Math.round(value))
}
export function formatNumber(value: number) {
return plain.format(Math.round(value))
}
export const vatNote = {
net: "Netto, zzgl. 19 % MwSt. Angebot für Unternehmen.",
gross: "Inkl. 19 % MwSt.",
"small-business": "Gemäß §19 UStG wird keine Umsatzsteuer berechnet.",
}[pricing.vat]
/** Plain-text summary that prefills the contact form. */
export function summarize(state: CalculatorState, result: Estimate) {
const type = getProjectType(state.type)
const parts = [`Projekt: ${type.label}`]
if (type.pages) parts.push(`Seiten: ca. ${state.pages}`)
if (type.sizedByComplexity) {
const level = pricing.complexity.find((c) => c.id === state.complexity)
if (level) parts.push(`Umfang: ${level.label}`)
}
const extras = extrasFor(state.type).filter((extra) => state.extras.includes(extra.id))
if (extras.length) parts.push(`Extras: ${extras.map((extra) => extra.label).join(", ")}`)
const monthly = pricing.monthly.filter((service) => state.monthly.includes(service.id))
if (monthly.length) parts.push(`Laufend: ${monthly.map((service) => service.label).join(", ")}`)
if (state.express) parts.push("Tempo: Express")
parts.push(`Schätzung laut Preisrechner: ${formatEuro(result.low)} – ${formatEuro(result.high)}`)
return `Hallo since3,\n\n${parts.join("\n")}\n\nKurz zu meinem Vorhaben: `
}

View file

@ -0,0 +1,129 @@
// Pure logic of the live tools. No React here, so it stays easy to test.
export type OutputTone = "default" | "muted" | "success" | "error"
export type OutputLine = { text: string; tone?: OutputTone }
/* ── Contrast check (WCAG 2.2) ──────────────────────────────────────── */
export function parseHex(input: string): [number, number, number] | null {
const hex = input.trim().replace(/^#/, "")
const full =
hex.length === 3
? hex
.split("")
.map((c) => c + c)
.join("")
: hex
if (!/^[0-9a-f]{6}$/i.test(full)) return null
return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16)) as [
number,
number,
number,
]
}
function relativeLuminance([r, g, b]: [number, number, number]) {
const channel = (value: number) => {
const c = value / 255
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
}
export function contrastRatio(
a: [number, number, number],
b: [number, number, number]
) {
const [light, dark] = [relativeLuminance(a), relativeLuminance(b)].sort(
(x, y) => y - x
)
return (light + 0.05) / (dark + 0.05)
}
export function runContrastCheck(fg: string, bg: string): OutputLine[] {
const command: OutputLine = {
text: `$ kontrast --text ${fg} --hintergrund ${bg}`,
tone: "muted",
}
const fgRgb = parseHex(fg)
const bgRgb = parseHex(bg)
if (!fgRgb || !bgRgb) {
return [
command,
{ text: "fehler: bitte Farben als Hex angeben, z. B. #1a1a1a", tone: "error" },
]
}
// Floor instead of round, otherwise 4.499 would be reported as passing 4.5
const ratio = Math.floor(contrastRatio(fgRgb, bgRgb) * 100) / 100
const check = (label: string, min: number): OutputLine => {
const pass = ratio >= min
return {
text: `${label.padEnd(22)}${pass ? "bestanden" : "nicht bestanden"} (min. ${min}:1)`,
tone: pass ? "success" : "error",
}
}
return [
command,
{ text: `verhältnis ${ratio.toFixed(2)} : 1` },
check("AA fließtext", 4.5),
check("AA große schrift", 3),
check("AA bedienelemente", 3),
check("AAA fließtext", 7),
]
}
/* ── Search snippet check ───────────────────────────────────────────── */
const TITLE_RANGE = [30, 60] as const
const DESCRIPTION_RANGE = [70, 160] as const
function rangeLine(
label: string,
length: number,
[min, max]: readonly [number, number]
): OutputLine {
const ok = length >= min && length <= max
const hint = length < min ? "zu kurz" : length > max ? "wird abgeschnitten" : "passt"
return {
text: `${label.padEnd(16)}${String(length).padStart(3)} zeichen ${hint} (${min}–${max})`,
tone: ok ? "success" : "error",
}
}
export function runSnippetCheck(title: string, description: string): OutputLine[] {
const lines: OutputLine[] = [
{ text: "$ snippet --pruefen", tone: "muted" },
rangeLine("titel", title.trim().length, TITLE_RANGE),
rangeLine("beschreibung", description.trim().length, DESCRIPTION_RANGE),
]
const titleWords = new Set(
title
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter((word) => word.length > 3)
)
const shared = description
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter((word) => titleWords.has(word))
lines.push(
shared.length > 0
? { text: `suchbegriff in beiden: ${[...new Set(shared)].join(", ")}`, tone: "success" }
: {
text: "suchbegriff titel und beschreibung teilen kein wort",
tone: "error",
}
)
return lines
}
export function truncate(text: string, max: number) {
const trimmed = text.trim()
return trimmed.length > max ? `${trimmed.slice(0, max - 1).trimEnd()} …` : trimmed
}

View file

@ -0,0 +1,35 @@
import { z } from "zod"
// Shared between the server action and the form so both validate identically.
export const contactSchema = z.object({
name: z
.string()
.trim()
.min(2, "Bitte gib deinen Namen an."),
email: z
.string()
.trim()
.pipe(z.email("Bitte gib eine gültige E-Mail-Adresse an.")),
company: z.string().trim().max(120, "Maximal 120 Zeichen.").optional(),
message: z
.string()
.trim()
.min(20, "Erzähl uns in mindestens 20 Zeichen, worum es geht.")
.max(4000, "Maximal 4000 Zeichen."),
privacy: z.literal("on", {
error: "Bitte stimme der Verarbeitung deiner Angaben zu.",
}),
})
export type ContactInput = z.infer<typeof contactSchema>
export type ContactField = keyof ContactInput
export type ContactFormState = {
status: "idle" | "success" | "error"
message?: string
fieldErrors?: Partial<Record<ContactField, string[]>>
// Echoed back so the form keeps its values after a failed submit
values?: Partial<Record<ContactField, string>>
}
export const initialContactState: ContactFormState = { status: "idle" }

View file

@ -0,0 +1,50 @@
"use server"
import { z } from "zod"
import {
contactSchema,
type ContactFormState,
} from "@/modules/landingPage/schema/contact-schema"
export async function submitContact(
_prevState: ContactFormState,
formData: FormData
): Promise<ContactFormState> {
// Honeypot: real people never see or fill this field
if (formData.get("website")) {
return { status: "success", message: "Danke! Wir melden uns innerhalb von zwei Werktagen." }
}
const raw = {
name: String(formData.get("name") ?? ""),
email: String(formData.get("email") ?? ""),
company: String(formData.get("company") ?? "") || undefined,
message: String(formData.get("message") ?? ""),
privacy: formData.get("privacy") ?? undefined,
}
const parsed = contactSchema.safeParse(raw)
if (!parsed.success) {
return {
status: "error",
message: "Bitte prüfe die markierten Felder.",
fieldErrors: z.flattenError(parsed.error).fieldErrors,
values: {
name: raw.name,
email: raw.email,
company: raw.company,
message: raw.message,
},
}
}
// TODO: deliver the inquiry, e.g. via a mail provider (Resend, Postmark, SMTP).
// Keep personal data out of logs.
return {
status: "success",
message: "Danke! Wir melden uns innerhalb von zwei Werktagen.",
}
}

View file

@ -0,0 +1,57 @@
// TODO: replace the placeholders with real team photos.
const team = [
{ name: "[Name]", role: "Gründer, Entwicklung" },
{ name: "[Name]", role: "Gründer, Design und SEO" },
]
export function AboutSection() {
return (
<section
id="ueber-uns"
aria-labelledby="ueber-uns-title"
data-theme="light"
className="py-section"
>
<div className="container-site">
<p className="font-mono text-label text-fg-muted">(Über uns)</p>
<h2 id="ueber-uns-title" className="mt-6 text-mega type-poster">
Zwei Freunde.
<br />
<span className="text-outline">Ein Anspruch.</span>
</h2>
<div className="mt-20 grid gap-12 lg:grid-cols-12">
<ul className="grid grid-cols-2 gap-4 sm:gap-6 lg:col-span-7">
{team.map((person, index) => (
<li key={index} className={index === 1 ? "mt-16 md:mt-32" : undefined}>
<figure className="group flex flex-col gap-3" data-cursor="Hallo!">
<div className="clip-reveal relative grid aspect-3/4 place-items-center overflow-hidden rounded-glass bg-ink-900 font-mono text-label text-ink-400">
<span className="transition-[scale] duration-1000 ease-out-expo group-hover:scale-110">
Foto folgt
</span>
</div>
<figcaption className="flex justify-between font-mono text-label">
<span>{person.name}</span>
<span className="text-fg-muted">{person.role}</span>
</figcaption>
</figure>
</li>
))}
</ul>
<div className="flex flex-col justify-end gap-6 lg:col-span-4 lg:col-start-9">
<p className="text-statement type-poster">
Du redest mit denen, die bauen.
</p>
<p className="type-body text-fg-muted">
since3 ist eine kleine Agentur aus Steinfurt. Wir arbeiten auf
Augenhöhe, sagen ehrlich, was sinnvoll ist, und bauen Technik, die
schnell lädt, für alle funktioniert und Arbeit abnimmt, statt neue zu
machen.
</p>
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,53 @@
import { ArrowUpRight } from "lucide-react"
import { aiAssistants, aiPrompt } from "@/modules/landingPage/data/content"
import { Magnetic } from "@/modules/shared/ui/components/magnetic"
/** "Ask an AI about us": proof of the AI visibility we also sell. */
export function AiVisibilitySection() {
return (
<section aria-labelledby="ki-title" data-theme="light" className="pb-section">
<div className="container-site">
<div
data-theme="dark"
className="flex flex-col items-center rounded-[2.5rem] px-6 py-20 text-center md:py-28"
>
<p className="font-mono text-label text-ink-400">(KI-Sichtbarkeit)</p>
<h2 id="ki-title" className="mt-6 text-display type-poster">
Frag nicht uns.
<br />
<span className="text-outline">Frag eine KI.</span>
</h2>
<p className="mt-12 max-w-[60ch] rounded-2xl border border-white/15 bg-white/5 px-6 py-4 text-left font-mono text-label text-white">
<span className="text-ink-400">&gt; </span>
{aiPrompt}
<span aria-hidden="true" className="ml-1 inline-block h-4 w-2 translate-y-0.5 animate-pulse bg-white" />
</p>
<ul className="mt-10 flex flex-wrap justify-center gap-4">
{aiAssistants.map((assistant) => (
<li key={assistant.name}>
<Magnetic strength={0.3}>
<a
href={assistant.href}
target="_blank"
rel="noopener noreferrer"
className="group inline-flex h-14 items-center gap-2 rounded-full border border-white/25 px-7 font-medium transition-colors duration-300 hover:bg-white hover:text-black"
>
{assistant.name} fragen
<ArrowUpRight
aria-hidden
className="size-4 transition-transform duration-500 ease-out-expo group-hover:rotate-45"
/>
<span className="sr-only"> (öffnet in neuem Tab)</span>
</a>
</Magnetic>
</li>
))}
</ul>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,53 @@
import { ContactForm } from "@/modules/landingPage/forms/contact-form"
import { site } from "@/modules/shared/config/site"
export function ContactSection() {
return (
<section
id="kontakt"
aria-labelledby="kontakt-title"
data-theme="dark"
className="relative overflow-clip pt-section"
>
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-0 left-[-15%] size-[70vw] rounded-full bg-[radial-gradient(circle,rgb(255_255_255/0.1),transparent_65%)] blur-3xl"
/>
<div className="container-site relative">
<p className="font-mono text-label text-fg-muted">(Kontakt)</p>
<h2 id="kontakt-title" className="mt-6 text-mega type-poster">
Lass uns
<br />
<span className="text-outline">reden.</span>
</h2>
<div className="mt-16 grid gap-12 lg:grid-cols-12">
<div className="flex flex-col gap-10 lg:col-span-4">
<p className="type-body text-lead text-fg-muted">
Das Erstgespräch ist kostenlos. Erzähl uns, was du vorhast, wir
melden uns innerhalb von zwei Werktagen.
</p>
<div className="flex flex-col gap-3">
<a
href={`mailto:${site.email}`}
className="group w-fit text-title font-medium"
>
<span className="bg-[linear-gradient(currentColor,currentColor)] bg-size-[0%_2px] bg-bottom-left bg-no-repeat pb-1 transition-[background-size] duration-500 ease-out-expo group-hover:bg-size-[100%_2px]">
{site.email}
</span>
</a>
<a href={site.phoneHref} className="w-fit font-mono text-label text-fg-muted hover:text-fg">
{site.phone}
</a>
</div>
</div>
<div className="glass p-6 sm:p-10 lg:col-span-8">
<ContactForm />
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,36 @@
"use client"
import { useEffect, useRef } from "react"
import { animate, useInView } from "motion/react"
/**
* Counts from 0 to `value` once it scrolls into view.
* The server renders the final number, so it is correct without JavaScript.
*/
export function CountUp({ value, className }: { value: number; className?: string }) {
const ref = useRef<HTMLSpanElement>(null)
const inView = useInView(ref, { once: true, margin: "0px 0px -15% 0px" })
useEffect(() => {
const el = ref.current
if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
if (!inView) {
el.textContent = "0"
return
}
const controls = animate(0, value, {
duration: 1.8,
ease: [0.16, 1, 0.3, 1],
onUpdate: (latest) => {
el.textContent = String(Math.round(latest))
},
})
return () => controls.stop()
}, [inView, value])
return (
<span ref={ref} className={className}>
{value}
</span>
)
}

View file

@ -0,0 +1,83 @@
import { ArrowDownRight } from "lucide-react"
import { site } from "@/modules/shared/config/site"
import { Magnetic } from "@/modules/shared/ui/components/magnetic"
import { HeroShader } from "./hero-shader"
import { KineticHeadline } from "./kinetic-headline"
export function HeroSection() {
return (
<section
aria-labelledby="hero-title"
data-theme="dark"
className="relative isolate flex min-h-svh flex-col overflow-clip"
>
<HeroShader className="absolute inset-0 -z-10 size-full" />
{/* Keeps header, label and headline readable on the brightest parts of the shader */}
<div
aria-hidden="true"
className="absolute inset-0 -z-10 bg-linear-to-t from-black via-black/45 to-black/10"
/>
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 -z-10 h-56 bg-linear-to-b from-black/85 to-transparent"
/>
<div className="container-site flex flex-1 flex-col justify-end pt-32 pb-8 md:pb-10">
<div
className="hero-fade flex items-center justify-between font-mono text-label text-ink-300"
style={{ "--d": "0ms" } as React.CSSProperties}
>
<span>(Digitalagentur aus {site.city})</span>
<span className="hidden sm:inline">52.15° N / 7.34° E</span>
</div>
<div id="hero-title" className="mt-6">
<KineticHeadline
lines={["Websites und", "Software, die", "arbeiten."]}
className="text-mega"
/>
</div>
<div className="mt-10 grid items-end gap-8 border-t border-white/15 pt-6 md:grid-cols-12">
<p
className="hero-fade type-body text-lead text-ink-300 md:col-span-6 lg:col-span-5"
style={{ "--d": "150ms" } as React.CSSProperties}
>
Wir entwickeln Websites, Web-Anwendungen und KI-Automatisierungen
für Unternehmen, die Ergebnisse sehen wollen statt Präsentationen.
</p>
<div
className="hero-fade flex items-center justify-between gap-6 md:col-span-6 md:justify-end lg:col-span-7"
style={{ "--d": "300ms" } as React.CSSProperties}
>
<a
href="#projekte"
className="group flex items-center gap-2 font-mono text-label text-ink-300 hover:text-white"
>
<ArrowDownRight
aria-hidden
className="size-4 transition-transform duration-500 ease-out-expo group-hover:rotate-45"
/>
Arbeiten ansehen
</a>
<Magnetic strength={0.3}>
<a
href="#kontakt"
className="group grid size-32 place-items-center rounded-full bg-white text-center text-sm font-semibold text-black transition-[scale] duration-500 ease-out-expo hover:scale-110 md:size-40 md:text-base"
>
<span>
Projekt
<br />
starten
</span>
</a>
</Magnetic>
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,330 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import { onIntroEnd } from "@/modules/shared/lib/intro"
const VERTEX = `
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`
// Monochrome liquid chrome: domain-warped fbm noise turned into sharp
// specular bands. The pointer acts as a lens that pushes the liquid away.
const FRAGMENT = `
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform vec2 u_res;
uniform float u_time;
uniform vec2 u_mouse;
uniform float u_force;
uniform float u_reveal;
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
u.y
);
}
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
mat2 rotate = mat2(1.6, 1.2, -1.2, 1.6);
for (int i = 0; i < 5; i++) {
value += amplitude * noise(p);
p = rotate * p;
amplitude *= 0.5;
}
return value;
}
void main() {
vec2 p = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y;
vec2 m = (u_mouse - 0.5 * u_res) / u_res.y;
vec2 dm = p - m;
float lens = exp(-dot(dm, dm) * 7.0) * u_force;
p -= dm * lens * 0.6;
float t = u_time * 0.045;
vec2 q = vec2(fbm(p * 1.3 + vec2(0.0, t)), fbm(p * 1.3 + vec2(5.2, -t)));
vec2 r = vec2(
fbm(p * 1.3 + 2.8 * q + vec2(1.7, 9.2) + 0.9 * t),
fbm(p * 1.3 + 2.8 * q + vec2(8.3, 2.8) - 0.7 * t)
);
float f = fbm(p * 1.3 + 3.0 * r);
float bands = 0.5 + 0.5 * cos(6.28318 * (f * 2.4 + r.y * 0.8 + lens * 0.9));
float chrome = pow(bands, 6.0) * 1.15 + pow(f, 2.5) * 0.4;
// Darker towards the bottom left, where the headline sits
vec2 uv = gl_FragCoord.xy / u_res;
float shade = smoothstep(-0.15, 1.1, uv.x * 0.5 + uv.y * 0.85);
float c = chrome * mix(0.18, 1.0, shade);
c *= smoothstep(1.7, 0.2, length(p * vec2(0.7, 1.0)));
c *= u_reveal;
c += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.045;
gl_FragColor = vec4(vec3(clamp(c, 0.0, 1.0)), 1.0);
}
`
// Rendering below device resolution keeps the GPU cost low; the image is soft anyway
const RENDER_SCALE = 0.5
const REVEAL_MS = 1800
type Renderer = {
draw: (uniforms: {
width: number
height: number
time: number
mouseX: number
mouseY: number
force: number
reveal: number
}) => void
dispose: () => void
}
function compile(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.warn("[HeroShader]", gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
/** Sets up program and geometry. Returns null if WebGL is not usable. */
function createRenderer(gl: WebGLRenderingContext): Renderer | null {
if (gl.isContextLost()) return null
const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX)
const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT)
const program = gl.createProgram()
if (!vertex || !fragment || !program) return null
gl.attachShader(program, vertex)
gl.attachShader(program, fragment)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.warn("[HeroShader]", gl.getProgramInfoLog(program))
return null
}
gl.useProgram(program)
// One triangle that covers the whole viewport
const buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
const position = gl.getAttribLocation(program, "a_position")
gl.enableVertexAttribArray(position)
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0)
const uRes = gl.getUniformLocation(program, "u_res")
const uTime = gl.getUniformLocation(program, "u_time")
const uMouse = gl.getUniformLocation(program, "u_mouse")
const uForce = gl.getUniformLocation(program, "u_force")
const uReveal = gl.getUniformLocation(program, "u_reveal")
return {
draw({ width, height, time, mouseX, mouseY, force, reveal }) {
gl.viewport(0, 0, width, height)
gl.uniform2f(uRes, width, height)
gl.uniform1f(uTime, time)
gl.uniform2f(uMouse, mouseX, mouseY)
gl.uniform1f(uForce, force)
gl.uniform1f(uReveal, reveal)
gl.drawArrays(gl.TRIANGLES, 0, 3)
},
// Frees GPU resources but keeps the context alive. Losing the context on
// purpose would break React Strict Mode, which mounts effects twice.
dispose() {
if (gl.isContextLost()) return
gl.deleteBuffer(buffer)
gl.deleteProgram(program)
gl.deleteShader(vertex)
gl.deleteShader(fragment)
},
}
}
export function HeroShader({ className }: { className?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
// The CSS fallback below stays visible until the first frame is drawn
const [ready, setReady] = useState(false)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const gl = canvas.getContext("webgl", {
antialias: false,
alpha: false,
depth: false,
powerPreference: "high-performance",
})
if (!gl) return
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
const scale = Math.min(window.devicePixelRatio, 2) * RENDER_SCALE
let renderer = createRenderer(gl)
let width = 1
let height = 1
let mouseX = 0
let mouseY = 0
let targetX = 0
let targetY = 0
let force = 0
let targetForce = 0
let revealStart = -1
let visible = true
let frame = 0
let shown = false
const start = performance.now()
const resize = () => {
width = Math.max(1, Math.round(canvas.clientWidth * scale))
height = Math.max(1, Math.round(canvas.clientHeight * scale))
canvas.width = width
canvas.height = height
if (!targetX) {
targetX = mouseX = width * 0.7
targetY = mouseY = height * 0.6
}
}
const draw = (now: number) => {
if (!renderer) return
mouseX += (targetX - mouseX) * 0.06
mouseY += (targetY - mouseY) * 0.06
force += (targetForce - force) * 0.04
targetForce *= 0.985
const progress = revealStart < 0 ? 0 : Math.min((now - revealStart) / REVEAL_MS, 1)
renderer.draw({
width,
height,
time: (now - start) / 1000,
mouseX,
mouseY,
force,
reveal: reduced ? 1 : 1 - Math.pow(1 - progress, 3),
})
if (!shown) {
shown = true
setReady(true)
}
}
const loop = (now: number) => {
if (!visible || document.hidden || !renderer) {
frame = 0
return
}
draw(now)
frame = requestAnimationFrame(loop)
}
const play = () => {
if (reduced) draw(performance.now())
else if (!frame) frame = requestAnimationFrame(loop)
}
const onPointerMove = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect()
targetX = (event.clientX - rect.left) * scale
// WebGL's origin is bottom left
targetY = (rect.bottom - event.clientY) * scale
targetForce = 1
}
// The GPU may drop the context (driver reset, too many tabs): show the
// fallback meanwhile and rebuild once the browser restores it.
const onContextLost = (event: Event) => {
event.preventDefault()
cancelAnimationFrame(frame)
frame = 0
renderer = null
shown = false
setReady(false)
}
const onContextRestored = () => {
renderer = createRenderer(gl)
resize()
play()
}
const observer = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting
if (visible) play()
})
const resizeObserver = new ResizeObserver(() => {
resize()
if (reduced) draw(performance.now())
})
const onVisibility = () => {
if (!document.hidden) play()
}
resize()
observer.observe(canvas)
resizeObserver.observe(canvas)
canvas.addEventListener("webglcontextlost", onContextLost)
canvas.addEventListener("webglcontextrestored", onContextRestored)
document.addEventListener("visibilitychange", onVisibility)
window.addEventListener("pointermove", onPointerMove, { passive: true })
const cleanupIntro = onIntroEnd(() => {
revealStart = performance.now()
})
play()
return () => {
cleanupIntro()
cancelAnimationFrame(frame)
observer.disconnect()
resizeObserver.disconnect()
canvas.removeEventListener("webglcontextlost", onContextLost)
canvas.removeEventListener("webglcontextrestored", onContextRestored)
document.removeEventListener("visibilitychange", onVisibility)
window.removeEventListener("pointermove", onPointerMove)
renderer?.dispose()
}
}, [])
return (
<div aria-hidden="true" className={cn("pointer-events-none overflow-hidden bg-black", className)}>
{/* Static fallback when WebGL is unavailable (e.g. hardware acceleration off) */}
<div className="absolute inset-0 bg-[radial-gradient(60%_50%_at_75%_25%,rgb(255_255_255/0.28),transparent_70%),radial-gradient(40%_35%_at_30%_10%,rgb(255_255_255/0.14),transparent_70%),radial-gradient(50%_40%_at_90%_70%,rgb(255_255_255/0.1),transparent_70%)]" />
<canvas
ref={canvasRef}
className={cn(
"absolute inset-0 size-full transition-opacity duration-700",
ready ? "opacity-100" : "opacity-0"
)}
/>
</div>
)
}

View file

@ -0,0 +1,127 @@
"use client"
import { useEffect, useRef } from "react"
import { cn } from "@/lib/utils"
import { onIntroEnd } from "@/modules/shared/lib/intro"
// Resting state: heavy and condensed. Under the pointer: thin and wide.
const REST = { wght: 800, wdth: 75 }
const NEAR = { wght: 200, wdth: 125 }
const RADIUS = 280
const EASE = 0.14
/**
* Headline whose letters react to the pointer along the font's weight and
* width axes. Each letter also slides in after the preloader (CSS).
*/
export function KineticHeadline({
lines,
className,
}: {
lines: string[]
className?: string
}) {
const rootRef = useRef<HTMLHeadingElement>(null)
useEffect(() => {
const root = rootRef.current
if (!root) return
const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
if (!fine || reduced) return
const chars = Array.from(root.querySelectorAll<HTMLSpanElement>("[data-char]"))
const current = chars.map(() => 0)
let pointerX = -9999
let pointerY = -9999
let frame = 0
let active = false
const tick = () => {
// Read all positions first, then write, to avoid layout thrashing
const rects = chars.map((char) => char.getBoundingClientRect())
let moving = false
rects.forEach((rect, index) => {
const dx = pointerX - (rect.left + rect.width / 2)
const dy = pointerY - (rect.top + rect.height / 2)
const distance = Math.hypot(dx, dy)
const raw = Math.max(0, 1 - distance / RADIUS)
const target = raw * raw * (3 - 2 * raw)
current[index] += (target - current[index]) * EASE
if (Math.abs(target - current[index]) > 0.002) moving = true
})
chars.forEach((char, index) => {
const t = current[index]
const wght = REST.wght + (NEAR.wght - REST.wght) * t
const wdth = REST.wdth + (NEAR.wdth - REST.wdth) * t
char.style.fontVariationSettings = `"wght" ${wght.toFixed(0)}, "wdth" ${wdth.toFixed(1)}`
})
frame = moving ? requestAnimationFrame(tick) : 0
}
const onMove = (event: PointerEvent) => {
if (!active) return
pointerX = event.clientX
pointerY = event.clientY
if (!frame) frame = requestAnimationFrame(tick)
}
const onLeave = () => {
pointerX = pointerY = -9999
if (!frame) frame = requestAnimationFrame(tick)
}
// Wait for the letters to finish sliding in
let timeout = 0
const cleanupIntro = onIntroEnd(() => {
timeout = window.setTimeout(() => (active = true), 1400)
})
window.addEventListener("pointermove", onMove, { passive: true })
document.documentElement.addEventListener("pointerleave", onLeave)
return () => {
cleanupIntro()
window.clearTimeout(timeout)
cancelAnimationFrame(frame)
window.removeEventListener("pointermove", onMove)
document.documentElement.removeEventListener("pointerleave", onLeave)
}
}, [])
let charIndex = 0
return (
<h1 ref={rootRef} className={cn("type-poster", className)}>
<span className="sr-only">{lines.join(" ")}</span>
{lines.map((line) => (
<span
key={line}
aria-hidden="true"
className="block overflow-clip pt-[0.04em] pb-[0.02em] whitespace-nowrap"
>
{line.split(" ").map((word, wordIndex) => (
<span key={wordIndex} className="inline-block">
{word.split("").map((char) => {
const index = charIndex++
return (
<span
key={index}
data-char
className="hero-char"
style={{ "--i": index } as React.CSSProperties}
>
{char}
</span>
)
})}
{wordIndex < line.split(" ").length - 1 ? " " : null}
</span>
))}
</span>
))}
</h1>
)
}

View file

@ -0,0 +1,94 @@
"use client"
import { useRef } from "react"
import {
motion,
useAnimationFrame,
useMotionValue,
useReducedMotion,
useScroll,
useSpring,
useTransform,
useVelocity,
} from "motion/react"
import { cn } from "@/lib/utils"
function wrap(min: number, max: number, value: number) {
const range = max - min
return ((((value - min) % range) + range) % range) + min
}
/**
* Endless text band. Scrolling speeds it up, scrolling up reverses it.
* `baseVelocity` is in percent of one copy per second.
*/
function VelocityRow({
items,
baseVelocity,
className,
}: {
items: string[]
baseVelocity: number
className?: string
}) {
const reduced = useReducedMotion()
const baseX = useMotionValue(0)
const { scrollY } = useScroll()
const velocity = useVelocity(scrollY)
const smoothVelocity = useSpring(velocity, { damping: 50, stiffness: 400 })
const factor = useTransform(smoothVelocity, [0, 1000], [0, 4], { clamp: false })
const x = useTransform(baseX, (value) => `${wrap(-50, 0, value)}%`)
const direction = useRef(1)
useAnimationFrame((_, delta) => {
if (reduced) return
const f = factor.get()
if (f < 0) direction.current = -1
else if (f > 0) direction.current = 1
let move = direction.current * baseVelocity * (delta / 1000)
move += move * Math.abs(f)
baseX.set(baseX.get() + move)
})
const content = (
<span className="flex shrink-0 items-center">
{items.map((item, index) => (
<span key={index} className="flex items-center">
<span className="px-[0.35em]">{item}</span>
<span className="text-[0.5em]">✦</span>
</span>
))}
</span>
)
return (
<div className={cn("flex overflow-clip whitespace-nowrap", className)}>
<motion.div style={{ x }} className="flex w-max">
{content}
{content}
</motion.div>
</div>
)
}
/** Two crossing bands between hero and statement. */
export function MarqueeBand({ items }: { items: string[] }) {
return (
<div aria-hidden="true" className="relative h-[clamp(9rem,20vw,19rem)] overflow-x-clip bg-black">
<div className="absolute inset-x-[-5%] top-1/2 -translate-y-1/2 rotate-3">
<VelocityRow
items={items}
baseVelocity={-2}
className="py-2 text-display type-poster text-outline [--stroke-color:var(--color-ink-600)]"
/>
</div>
<div
data-theme="light"
className="absolute inset-x-[-5%] top-1/2 -translate-y-1/2 -rotate-2 shadow-[0_30px_80px_-20px_rgb(0_0_0/0.8)]"
>
<VelocityRow items={items} baseVelocity={3} className="py-3 text-display type-poster" />
</div>
</div>
)
}

View file

@ -0,0 +1,49 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { animate } from "motion/react"
/**
* Number that rolls smoothly to its new value. React renders only the first
* value; every later update is written by the animation, so the text never
* flashes to the final value before rolling.
* `format` must be a stable function (defined at module level).
*/
export function AnimatedNumber({
value,
format,
className,
}: {
value: number
format: (value: number) => string
className?: string
}) {
const ref = useRef<HTMLSpanElement>(null)
const displayed = useRef(value)
const [initial] = useState(value)
useEffect(() => {
const el = ref.current
if (!el || displayed.current === value) return
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
displayed.current = value
el.textContent = format(value)
return
}
const controls = animate(displayed.current, value, {
duration: 0.7,
ease: [0.16, 1, 0.3, 1],
onUpdate: (latest) => {
displayed.current = latest
el.textContent = format(latest)
},
})
return () => controls.stop()
}, [value, format])
return (
<span ref={ref} className={className}>
{format(initial)}
</span>
)
}

View file

@ -0,0 +1,376 @@
"use client"
import { useEffect, useId, useRef, useState } from "react"
import { ArrowDown, ArrowRight, Check, Plus } from "lucide-react"
import { play } from "@/lib/sound"
import { cn } from "@/lib/utils"
import { pricing, type ProjectTypeId } from "@/modules/landingPage/data/pricing"
import { prefillContact } from "@/modules/landingPage/lib/contact-prefill"
import {
estimate,
extrasFor,
formatEuro,
formatNumber,
getProjectType,
initialCalculatorState,
summarize,
toDisplayPrice,
vatNote,
type CalculatorState,
} from "@/modules/landingPage/lib/pricing"
import { AnimatedNumber } from "./animated-number"
// Selectable tile: the real input is visually hidden, the label shows its state
const tileClass =
"group relative flex cursor-pointer flex-col gap-1 rounded-2xl border border-line p-5 transition-[background-color,border-color,color] duration-300 hover:border-fg/60 has-checked:border-fg has-checked:bg-fg has-checked:text-bg has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-fg"
const tileMuted = "text-fg-muted transition-colors duration-300 group-has-checked:text-bg/70"
export function PriceCalculator({ className }: { className?: string }) {
const id = useId()
const [state, setState] = useState<CalculatorState>(initialCalculatorState)
const rootRef = useRef<HTMLDivElement>(null)
const resultRef = useRef<HTMLDivElement>(null)
const [barVisible, setBarVisible] = useState(false)
const type = getProjectType(state.type)
const result = estimate(state)
const extras = extrasFor(state.type)
const update = (patch: Partial<CalculatorState>) => setState((current) => ({ ...current, ...patch }))
const toggleIn = (key: "extras" | "monthly", value: string) =>
setState((current) => ({
...current,
[key]: current[key].includes(value)
? current[key].filter((item) => item !== value)
: [...current[key], value],
}))
const selectType = (next: ProjectTypeId) => {
const nextType = getProjectType(next)
update({
type: next,
pages: nextType.pages ? Math.min(Math.max(state.pages, 1), nextType.pages.max) : state.pages,
})
}
// Mobile: a floating price bar while the calculator is on screen but the result card is not
useEffect(() => {
const root = rootRef.current
const card = resultRef.current
if (!root || !card) return
let rootVisible = false
let cardVisible = false
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.target === root) rootVisible = entry.isIntersecting
if (entry.target === card) cardVisible = entry.isIntersecting
}
setBarVisible(rootVisible && !cardVisible)
})
observer.observe(root)
observer.observe(card)
return () => observer.disconnect()
}, [])
// Steps adapt to the project type, so their numbers are computed
let step = 0
const next = () => ++step
return (
<div ref={rootRef} className={cn("grid gap-12 lg:grid-cols-12", className)}>
<div className="lg:col-span-7">
<Step index={next()} title="Was brauchst du?">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{pricing.projectTypes.map((option) => (
<label key={option.id} className={tileClass}>
<input
type="radio"
name={`${id}-type`}
value={option.id}
checked={state.type === option.id}
onChange={() => selectType(option.id)}
className="sr-only"
/>
<span className="flex items-start justify-between gap-3">
<span className="text-xl leading-tight font-semibold">{option.label}</span>
<Check
aria-hidden
className="size-4 shrink-0 opacity-0 transition-opacity group-has-checked:opacity-100"
/>
</span>
<span className={cn("text-sm", tileMuted)}>{option.description}</span>
<span className={cn("mt-3 font-mono text-label", tileMuted)}>
ab {formatEuro(toDisplayPrice(option.base))}
</span>
</label>
))}
</div>
</Step>
{type.pages ? (
<Step index={next()} title="Wie viele Seiten?">
<div className="flex items-end justify-between gap-4">
<output
htmlFor={`${id}-pages`}
className="text-display leading-none tabular-nums type-poster"
>
{state.pages}
</output>
<span className="pb-2 font-mono text-label text-fg-muted">
{state.pages === 1 ? "Seite" : "Seiten"}
</span>
</div>
<input
id={`${id}-pages`}
type="range"
min={1}
max={type.pages.max}
value={state.pages}
onChange={(event) => {
update({ pages: Number(event.target.value) })
// A tick per step makes the slider feel mechanical
play("hover")
}}
aria-label="Anzahl Seiten"
className="mt-6 h-2 w-full cursor-pointer accent-fg"
/>
<p className="mt-3 font-mono text-label text-fg-muted">
{type.pages.included} Seiten inklusive, jede weitere{" "}
{formatEuro(toDisplayPrice(type.pages.pricePerPage))}
</p>
</Step>
) : null}
{type.sizedByComplexity ? (
<Step index={next()} title="Wie umfangreich?">
<div className="grid gap-3 sm:grid-cols-3">
{pricing.complexity.map((level) => (
<label key={level.id} className={tileClass}>
<input
type="radio"
name={`${id}-complexity`}
value={level.id}
checked={state.complexity === level.id}
onChange={() => update({ complexity: level.id })}
className="sr-only"
/>
<span className="text-lg font-semibold">{level.label}</span>
<span className={cn("text-sm", tileMuted)}>{level.hint}</span>
</label>
))}
</div>
</Step>
) : null}
<Step index={next()} title="Was soll dazu?" hint="Mehrfachauswahl">
<div className="flex flex-wrap gap-2">
{extras.map((extra) => (
<label
key={extra.id}
className="group flex cursor-pointer items-center gap-2.5 rounded-full border border-line py-2.5 pr-4 pl-3 text-sm transition-[background-color,border-color,color] duration-300 hover:border-fg/60 has-checked:border-fg has-checked:bg-fg has-checked:text-bg has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-fg"
>
<input
type="checkbox"
checked={state.extras.includes(extra.id)}
onChange={() => toggleIn("extras", extra.id)}
className="sr-only"
/>
<Plus
aria-hidden
className="size-3.5 transition-transform duration-300 ease-out-expo group-has-checked:rotate-45"
/>
{extra.label}
<span className="font-mono text-label opacity-60">
+{formatEuro(toDisplayPrice(extra.price))}
</span>
</label>
))}
</div>
<ul className="mt-6 flex flex-wrap gap-x-5 gap-y-2 font-mono text-label text-fg-muted">
{pricing.included.map((item) => (
<li key={item} className="flex items-center gap-1.5">
<Check aria-hidden className="size-3.5 text-fg" />
{item}
<span className="sr-only"> (immer inklusive)</span>
</li>
))}
</ul>
</Step>
<Step index={next()} title="Betreuung danach?" hint="Optional, monatlich">
<div className="grid gap-3 sm:grid-cols-2">
{pricing.monthly.map((service) => (
<label key={service.id} className={tileClass}>
<input
type="checkbox"
checked={state.monthly.includes(service.id)}
onChange={() => toggleIn("monthly", service.id)}
className="sr-only"
/>
<span className="flex items-start justify-between gap-3">
<span className="text-lg font-semibold">{service.label}</span>
<Check
aria-hidden
className="size-4 shrink-0 opacity-0 transition-opacity group-has-checked:opacity-100"
/>
</span>
<span className={cn("text-sm", tileMuted)}>{service.description}</span>
<span className={cn("mt-3 font-mono text-label", tileMuted)}>
{formatEuro(toDisplayPrice(service.price))} / Monat
</span>
</label>
))}
</div>
</Step>
<Step index={next()} title="Wie schnell?">
<div className="grid gap-3 sm:grid-cols-2">
{[
{ express: false, label: "Standard", hint: "Normale Planung, kein Aufpreis" },
{
express: true,
label: pricing.express.label,
hint: `${pricing.express.hint}, +${Math.round((pricing.express.factor - 1) * 100)} %`,
},
].map((option) => (
<label key={option.label} className={tileClass}>
<input
type="radio"
name={`${id}-tempo`}
checked={state.express === option.express}
onChange={() => update({ express: option.express })}
className="sr-only"
/>
<span className="text-lg font-semibold">{option.label}</span>
<span className={cn("text-sm", tileMuted)}>{option.hint}</span>
</label>
))}
</div>
</Step>
</div>
{/* Result */}
<div className="lg:col-span-5">
<div
ref={resultRef}
id="preis-ergebnis"
data-theme="dark"
className="rounded-glass p-6 sm:p-8 lg:sticky lg:top-28"
>
<div className="flex items-center justify-between gap-4">
<p className="font-mono text-label text-fg-muted">(Deine Schätzung)</p>
{pricing.isExample ? (
<span className="rounded-full border border-line px-3 py-1 font-mono text-label text-fg-muted">
Beispielpreise
</span>
) : null}
</div>
<p className="mt-6 text-[clamp(2.5rem,5vw,4.5rem)] leading-[0.9] tabular-nums type-poster">
<AnimatedNumber value={result.low} format={formatNumber} />
<span className="text-fg-muted"> – </span>
<br className="sm:hidden" />
<AnimatedNumber value={result.high} format={formatEuro} />
</p>
<dl className="mt-6 grid grid-cols-2 gap-4 border-y border-line py-4 font-mono text-label">
<div>
<dt className="text-fg-muted">Dauer</dt>
<dd className="mt-1">
ca. {result.weeks[0]}–{result.weeks[1]} Wochen
</dd>
</div>
<div>
<dt className="text-fg-muted">Laufend</dt>
<dd className="mt-1">
{result.monthly ? `${formatEuro(result.monthly)} / Monat` : "keine"}
</dd>
</div>
</dl>
<p className="mt-4 font-mono text-label text-fg-muted">Zusammensetzung (Richtwert)</p>
<ul className="mt-2 flex flex-col gap-1.5 font-mono text-label">
{result.lines.map((line) => (
<li key={line.label} className="flex justify-between gap-4">
<span className="text-fg-muted">{line.label}</span>
<span className="tabular-nums">{formatEuro(line.amount)}</span>
</li>
))}
</ul>
<p className="mt-6 text-xs leading-relaxed text-fg-muted">
{vatNote} Unverbindliche Schätzung. Den verbindlichen Festpreis bekommst du
nach dem kostenlosen Erstgespräch.
</p>
<a
href="#kontakt"
onClick={() => prefillContact(summarize(state, result))}
className="group mt-6 flex h-14 w-full items-center justify-center gap-2 rounded-full bg-white font-medium text-black transition-[scale] duration-500 ease-out-expo hover:scale-[1.02]"
>
Mit dieser Auswahl anfragen
<ArrowRight
aria-hidden
className="size-4 transition-transform duration-300 group-hover:translate-x-1"
/>
</a>
{/* One calm announcement instead of every animation frame */}
<p className="sr-only" aria-live="polite">
Geschätzt {formatNumber(result.low)} bis {formatEuro(result.high)}, Dauer etwa{" "}
{result.weeks[0]} bis {result.weeks[1]} Wochen.
</p>
</div>
</div>
{/* Mobile: keeps the estimate in sight while choosing */}
<a
href="#preis-ergebnis"
inert={!barVisible}
aria-hidden={!barVisible}
className={cn(
"fixed inset-x-4 bottom-4 z-70 flex items-center justify-between gap-4 rounded-full bg-black py-3 pr-3 pl-6 text-white shadow-[0_20px_60px_-10px_rgb(0_0_0/0.6)] transition-[translate,opacity] duration-500 ease-out-expo lg:hidden",
barVisible ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-[150%] opacity-0"
)}
>
<span className="text-xl tabular-nums type-poster">
{formatNumber(result.low)} – {formatEuro(result.high)}
</span>
<span className="grid size-10 place-items-center rounded-full bg-white text-black">
<ArrowDown aria-hidden className="size-4" />
<span className="sr-only">Zur Schätzung</span>
</span>
</a>
</div>
)
}
function Step({
index,
title,
hint,
children,
}: {
index: number
title: string
hint?: string
children: React.ReactNode
}) {
return (
<fieldset className="min-w-0 border-t border-line py-8 first:border-t-0 first:pt-0">
<legend className="float-left mb-6 flex w-full items-baseline gap-4">
<span className="font-mono text-label text-fg-muted">
{String(index).padStart(2, "0")}
</span>
<span className="type-title">{title}</span>
{hint ? (
<span className="ml-auto hidden font-mono text-label text-fg-muted sm:inline">{hint}</span>
) : null}
</legend>
<div className="clear-both">{children}</div>
</fieldset>
)
}

View file

@ -0,0 +1,26 @@
import { PriceCalculator } from "./price-calculator"
export function PricingSection() {
return (
<section id="preise" aria-labelledby="preise-title" data-theme="light" className="py-section">
<div className="container-site">
<div className="flex flex-col justify-between gap-8 md:flex-row md:items-end">
<div>
<p className="font-mono text-label text-fg-muted">(Preise)</p>
<h2 id="preise-title" className="mt-6 text-display type-poster">
Was kostet
<br />
<span className="text-outline">das?</span>
</h2>
</div>
<p className="type-body max-w-[40ch] text-fg-muted">
Stell dein Projekt zusammen und sieh sofort eine ehrliche Preisspanne.
Keine versteckten Kosten, kein „Preis auf Anfrage“.
</p>
</div>
<PriceCalculator className="mt-16 md:mt-20" />
</div>
</section>
)
}

View file

@ -0,0 +1,64 @@
import { processSteps } from "@/modules/landingPage/data/content"
// Each card sticks a little lower than the previous one, so they stack up.
const cardThemes = ["light", "dark", "light", "dark"] as const
export function ProcessSection() {
return (
<section
id="prozess"
aria-labelledby="prozess-title"
data-theme="dark"
className="py-section"
>
<div className="container-site grid gap-12 lg:grid-cols-12 lg:gap-x-8">
<div className="lg:col-span-4">
<div className="flex flex-col gap-6 lg:sticky lg:top-32">
<p className="font-mono text-label text-fg-muted">(Prozess)</p>
{/* Sized to fit its four columns, so it never runs into the cards */}
<h2
id="prozess-title"
className="text-[clamp(2.75rem,6vw,6rem)] leading-[0.86] type-poster"
>
Vier
<br />
Schritte.
</h2>
<p className="type-body text-fg-muted">
Vom ersten Gespräch bis zum Launch. Mit Festpreis und ohne
Überraschungen.
</p>
</div>
</div>
{/* One empty column between title and cards */}
<ol className="flex flex-col gap-6 lg:col-span-7 lg:col-start-6">
{processSteps.map((step, index) => (
<li
key={step.title}
data-theme={cardThemes[index % cardThemes.length]}
style={{ top: `calc(7rem + ${index * 1.75}rem)` }}
className="sticky flex min-h-104 flex-col justify-between gap-12 rounded-glass border border-line p-6 shadow-[0_-20px_60px_-20px_rgb(0_0_0/0.6)] md:min-h-128 md:p-10"
>
<div className="flex items-start justify-between gap-6">
<span className="font-mono text-label text-fg-muted">
Schritt {index + 1} von {processSteps.length}
</span>
<span
aria-hidden="true"
className="text-[clamp(5rem,12vw,11rem)] leading-[0.8] type-poster text-outline"
>
{String(index + 1).padStart(2, "0")}
</span>
</div>
<div className="flex flex-col gap-4">
<h3 className="text-statement type-poster">{step.title}</h3>
<p className="type-body text-lead text-fg-muted">{step.description}</p>
</div>
</li>
))}
</ol>
</div>
</section>
)
}

View file

@ -0,0 +1,105 @@
import Image from "next/image"
import type { Project } from "@/modules/landingPage/data/content"
/**
* Abstract stand-in visuals until real screenshots exist.
* Each project gets its own composition so the track does not feel repetitive.
*/
export function ProjectVisual({ project, index }: { project: Project; index: number }) {
if (project.image) {
return (
<Image
src={project.image.src}
alt={project.image.alt}
fill
sizes="(min-width: 768px) 60vw, 90vw"
className="object-cover"
/>
)
}
const variant = index % 3
if (variant === 0) {
// Typography poster, sized so the longest word always fits the card
const words = project.title.split(" ")
const longest = Math.max(...words.map((word) => word.length))
const fontSize = Math.min(14, 70 / (longest * 0.5))
return (
<div className="flex size-full flex-col justify-center bg-ink-100 p-[6cqi] text-black">
{words.map((word, i) => (
<span
key={i}
className="block leading-[0.85] type-poster"
style={{ fontSize: `${fontSize}cqi`, paddingLeft: `${i * 6}cqi` }}
>
{word}
</span>
))}
</div>
)
}
if (variant === 1) {
// Interface skeleton
return (
<div className="grid size-full grid-cols-[22%_1fr] gap-3 bg-ink-900 p-5 md:p-8">
<div className="flex flex-col gap-2 rounded-xl bg-ink-800 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-2 rounded-full bg-ink-700" style={{ width: `${90 - i * 9}%` }} />
))}
</div>
<div className="grid grid-rows-[auto_1fr] gap-3">
<div className="grid grid-cols-3 gap-3">
{["128", "4.2k", "99%"].map((value) => (
<div key={value} className="rounded-xl bg-ink-800 p-3 md:p-4">
<div className="h-1.5 w-1/2 rounded-full bg-ink-700" />
<p className="mt-3 text-2xl text-white type-poster md:text-4xl">{value}</p>
</div>
))}
</div>
<div className="relative overflow-hidden rounded-xl bg-ink-800">
<svg viewBox="0 0 300 100" preserveAspectRatio="none" className="absolute inset-0 size-full">
<path
d="M0 85 C 30 80, 50 60, 80 62 S 130 30, 160 40 S 220 10, 250 18 S 290 5, 300 4"
fill="none"
stroke="white"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
<path
d="M0 85 C 30 80, 50 60, 80 62 S 130 30, 160 40 S 220 10, 250 18 S 290 5, 300 4 V 100 H 0 Z"
fill="white"
fillOpacity="0.06"
/>
</svg>
</div>
</div>
</div>
)
}
// Orbit: automation as moving parts
return (
<div className="relative grid size-full place-items-center overflow-hidden rounded-glass border border-line bg-ink-950">
{[1, 2, 3, 4].map((ring) => (
<div
key={ring}
className="absolute aspect-square rounded-full border border-white/15"
style={{ width: `${ring * 22}%` }}
/>
))}
{[0, 60, 150, 230, 300].map((angle, i) => (
<div
key={angle}
className="absolute size-2.5 rounded-full bg-white"
style={{
transform: `rotate(${angle}deg) translateX(${(i % 4) * 11 + 11}cqi)`,
}}
/>
))}
<p className="relative font-mono text-label text-white">input → ki → output</p>
</div>
)
}

View file

@ -0,0 +1,148 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { motion, useScroll, useTransform } from "motion/react"
import { projects } from "@/modules/landingPage/data/content"
import { Magnetic } from "@/modules/shared/ui/components/magnetic"
import { ProjectVisual } from "./project-visual"
/**
* Vertical scroll drives a horizontal track (md and up). On small screens the
* cards simply stack, because the track then has no horizontal overflow.
*/
export function ProjectsShowcase() {
const sectionRef = useRef<HTMLElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
const [distance, setDistance] = useState(0)
useEffect(() => {
const track = trackRef.current
if (!track) return
const desktop = window.matchMedia("(min-width: 48rem)")
const measure = () => {
setDistance(desktop.matches ? Math.max(0, track.scrollWidth - window.innerWidth) : 0)
}
const observer = new ResizeObserver(measure)
observer.observe(track)
desktop.addEventListener("change", measure)
return () => {
observer.disconnect()
desktop.removeEventListener("change", measure)
}
}, [])
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ["start start", "end end"],
})
const x = useTransform(scrollYProgress, (value) => -value * distance)
const progress = useTransform(scrollYProgress, (value) => (distance ? value : 0))
return (
<section
ref={sectionRef}
id="projekte"
aria-labelledby="projekte-title"
data-theme="dark"
style={{ height: distance ? `calc(100svh + ${distance}px)` : undefined }}
className="relative"
>
<div className="flex flex-col justify-center overflow-x-clip py-section md:sticky md:top-0 md:h-svh md:py-0">
<motion.div
ref={trackRef}
style={{ x }}
className="flex flex-col gap-6 px-gutter md:w-max md:flex-row md:items-end md:gap-8"
>
{/* Intro panel */}
<div className="flex shrink-0 flex-col justify-end gap-6 pb-4 md:h-[68svh] md:w-[38vw]">
<p className="font-mono text-label text-fg-muted">
(Ausgewählte Arbeiten — {String(projects.length).padStart(2, "0")})
</p>
<h2 id="projekte-title" className="text-display type-poster">
Was wir
<br />
gebaut
<br />
haben.
</h2>
<p className="type-body max-w-[36ch] text-fg-muted">
Websites, interne Tools und Automatisierungen. Scroll weiter.
</p>
</div>
{projects.map((project, index) => (
<article
key={project.slug}
data-cursor={project.url ? "Ansehen" : "Bald online"}
className="group flex w-full shrink-0 flex-col gap-4 md:w-[56vw] lg:w-[46vw]"
>
<div className="relative aspect-4/3 overflow-hidden rounded-glass [container-type:inline-size] md:aspect-auto md:h-[58svh]">
<div className="size-full transition-[scale] duration-1000 ease-out-expo group-hover:scale-[1.04]">
<ProjectVisual project={project} index={index} />
</div>
<span className="absolute top-4 left-4 rounded-full bg-black/60 px-3 py-1 font-mono text-label text-white backdrop-blur">
({String(index + 1).padStart(2, "0")})
</span>
</div>
<div className="flex items-start justify-between gap-6">
<div>
<h3 className="type-title">
{project.url ? (
<a href={project.url} className="hover:underline">
{project.title}
</a>
) : (
project.title
)}
</h3>
<p className="mt-2 text-fg-muted">{project.benefit}</p>
</div>
<dl className="shrink-0 text-right font-mono text-label">
<dt className="sr-only">Jahr</dt>
<dd>{project.year}</dd>
<dt className="sr-only">Kunde</dt>
<dd className="text-fg-muted">{project.client}</dd>
</dl>
</div>
<ul aria-label="Leistungen" className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<li
key={tag}
className="rounded-full border border-line px-3 py-1 font-mono text-label text-fg-muted"
>
{tag}
</li>
))}
</ul>
</article>
))}
{/* Outro: the next project could be yours */}
<div className="flex shrink-0 items-center justify-center py-12 md:h-[68svh] md:w-[34vw]">
<Magnetic strength={0.25}>
<a
href="#kontakt"
className="grid aspect-square w-64 place-items-center rounded-full bg-white p-8 text-center text-black transition-[scale] duration-700 ease-out-expo hover:scale-105 md:w-[22vw]"
>
<span className="text-statement type-poster">
Dein
<br />
Projekt?
</span>
</a>
</Magnetic>
</div>
</motion.div>
{/* Progress of the horizontal journey */}
<div aria-hidden="true" className="container-site absolute inset-x-0 bottom-8 hidden md:block">
<div className="h-px bg-line">
<motion.div style={{ scaleX: progress }} className="h-full origin-left bg-fg" />
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,68 @@
"use client"
import { useRef } from "react"
import {
motion,
useReducedMotion,
useScroll,
useTransform,
type MotionValue,
} from "motion/react"
import { cn } from "@/lib/utils"
function Word({
children,
progress,
range,
}: {
children: string
progress: MotionValue<number>
range: [number, number]
}) {
const opacity = useTransform(progress, range, [0.14, 1])
return (
<motion.span style={{ opacity }} className="inline">
{children}{" "}
</motion.span>
)
}
/** A statement whose words light up one after another while scrolling. */
export function ScrollStatement({
text,
className,
}: {
text: string
className?: string
}) {
const ref = useRef<HTMLParagraphElement>(null)
const reduced = useReducedMotion()
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start 0.85", "end 0.5"],
})
const words = text.split(" ")
if (reduced) {
return (
<p ref={ref} className={className}>
{text}
</p>
)
}
return (
<p ref={ref} className={cn(className)}>
{words.map((word, index) => (
<Word
key={index}
progress={scrollYProgress}
range={[index / words.length, (index + 1) / words.length]}
>
{word}
</Word>
))}
</p>
)
}

View file

@ -0,0 +1,66 @@
import { services } from "@/modules/landingPage/data/content"
/**
* Giant rows. On hover a black fill wipes up from the bottom, the name slides
* right and the details unfold. Touch devices always show the details.
* TODO: link each row to /leistungen/[slug] once the subpages exist.
*/
export function ServicesSection() {
return (
<section
id="leistungen"
aria-labelledby="leistungen-title"
data-theme="light"
className="pt-section"
>
<div className="container-site flex flex-col justify-between gap-8 pb-16 md:flex-row md:items-end">
<h2 id="leistungen-title" className="text-display type-poster">
Was wir
<br />
machen
</h2>
<p className="type-body max-w-[38ch] text-fg-muted">
Fünf Disziplinen, alles aus einer Hand. Ohne Subunternehmer, ohne
Stille Post zwischen Design und Entwicklung.
</p>
</div>
<ul className="border-b border-line">
{services.map((service, index) => (
<li key={service.slug} className="service-row border-t border-line">
<div className="container-site py-6 md:py-8">
{/*
* Flex with wrapping instead of fixed columns: long names like
* "Softwareentwicklung" push the tags onto their own line
* instead of running into them.
*/}
<div className="flex flex-wrap items-baseline gap-x-8 gap-y-3">
<span className="service-meta hidden w-16 shrink-0 font-mono text-label text-fg-muted md:block">
({String(index + 1).padStart(2, "0")})
</span>
<h3 className="service-name min-w-0 text-[clamp(2.25rem,6.5vw,6.5rem)] leading-[0.9] wrap-break-word type-poster">
{service.title}
</h3>
<ul
aria-label="Schwerpunkte"
className="service-meta flex w-full flex-wrap gap-x-4 gap-y-1 font-mono text-label text-fg-muted md:ml-auto md:w-auto md:justify-end"
>
{service.tags.map((tag) => (
<li key={tag}>{tag}</li>
))}
</ul>
</div>
<div className="service-details md:pl-24">
<div className="overflow-hidden">
<p className="service-meta type-body pt-4 text-lead text-fg-muted">
{service.description}
</p>
</div>
</div>
</div>
</li>
))}
</ul>
</section>
)
}

View file

@ -0,0 +1,24 @@
import { stats } from "@/modules/landingPage/data/content"
import { CountUp } from "./count-up"
export function StatsSection() {
return (
<section aria-label="since3 in Zahlen" data-theme="light" className="py-section">
<dl className="container-site grid grid-cols-2 gap-y-12 lg:grid-cols-4">
{stats.map((stat) => (
<div key={stat.label} className="flex flex-col gap-3 border-l border-line pl-4 md:pl-6">
<dt className="order-2 max-w-[20ch] font-mono text-label text-fg-muted">
{stat.label}
</dt>
<dd className="order-1 text-display leading-none tabular-nums type-poster">
{stat.prefix}
<CountUp value={stat.value} />
<span className="normal-case">{stat.suffix}</span>
</dd>
</div>
))}
</dl>
</section>
)
}

View file

@ -0,0 +1,43 @@
import { ToolPanel } from "./tools/tool-panel"
/** Usable software instead of a service description. */
export function ToolsSection() {
return (
<section
id="tools"
aria-labelledby="tools-title"
data-theme="dark"
className="relative overflow-clip py-section"
>
{/* Soft light behind the glass, so the blur has something to refract */}
<div
aria-hidden="true"
className="pointer-events-none absolute top-1/3 right-[-10%] size-[60vw] rounded-full bg-[radial-gradient(circle,rgb(255_255_255/0.14),transparent_65%)] blur-3xl"
/>
<div className="container-site relative">
<p className="font-mono text-label text-fg-muted">(Tools)</p>
<h2 id="tools-title" className="mt-6 text-display type-poster">
Nicht erzählen.
<br />
<span className="text-outline">Ausprobieren.</span>
</h2>
<div className="mt-16 grid gap-12 lg:grid-cols-12">
<div className="flex flex-col gap-6 lg:col-span-4">
<p className="type-body text-lead text-fg-muted">
Kleine Werkzeuge, die wir selbst täglich nutzen. Direkt im Browser,
ohne Anmeldung. Nichts wird an einen Server geschickt.
</p>
<p className="font-mono text-label text-fg-subtle">
Weitere Tools sind in Arbeit.
</p>
</div>
<div className="lg:col-span-8">
<ToolPanel />
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,73 @@
"use client"
import { useId, useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { parseHex, runContrastCheck, type OutputLine } from "@/modules/landingPage/lib/tools"
import { ToolOutput } from "./tool-output"
export function ContrastTool() {
const id = useId()
const [fg, setFg] = useState("#767676")
const [bg, setBg] = useState("#ffffff")
const [lines, setLines] = useState<OutputLine[] | null>(null)
const preview = parseHex(fg) && parseHex(bg) ? { color: fg, background: bg } : undefined
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setLines(runContrastCheck(fg, bg))
}
return (
<form onSubmit={onSubmit} className="grid gap-6">
<p className="text-ink-300">
Prüft, ob Text auf einem Hintergrund die WCAG-Kontrastwerte erfüllt, die
das BFSG verlangt.
</p>
<div className="grid gap-4 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
<div className="grid gap-2.5">
<Label htmlFor={`${id}-fg`}>Textfarbe</Label>
<Input
id={`${id}-fg`}
value={fg}
onChange={(event) => setFg(event.target.value)}
spellCheck={false}
autoComplete="off"
className="font-mono"
/>
</div>
<div className="grid gap-2.5">
<Label htmlFor={`${id}-bg`}>Hintergrund</Label>
<Input
id={`${id}-bg`}
value={bg}
onChange={(event) => setBg(event.target.value)}
spellCheck={false}
autoComplete="off"
className="font-mono"
/>
</div>
<div
aria-hidden="true"
style={preview}
className="grid h-12 place-items-center rounded-xl border border-white/10 px-5 text-sm font-medium sm:w-24"
>
Aa
</div>
</div>
<div>
<Button type="submit" variant="solid">
Kontrast prüfen
</Button>
</div>
<ToolOutput lines={lines} placeholder="Ergebnis erscheint hier." />
</form>
)
}

View file

@ -0,0 +1,71 @@
"use client"
import { useId, useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { runSnippetCheck, truncate, type OutputLine } from "@/modules/landingPage/lib/tools"
import { ToolOutput } from "./tool-output"
export function SnippetTool() {
const id = useId()
const [title, setTitle] = useState("Webdesign in Steinfurt – since3 Digitalagentur")
const [description, setDescription] = useState(
"Schnelle, barrierefreie Websites aus Steinfurt. Kostenloses Erstgespräch vereinbaren."
)
const [lines, setLines] = useState<OutputLine[] | null>(null)
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setLines(runSnippetCheck(title, description))
}
return (
<form onSubmit={onSubmit} className="grid gap-6">
<p className="text-ink-300">
Zeigt, wie dein Suchergebnis aussieht, und prüft Länge und Suchbegriffe
von Titel und Beschreibung.
</p>
<div className="grid gap-4">
<div className="grid gap-2.5">
<Label htmlFor={`${id}-title`}>Seitentitel</Label>
<Input
id={`${id}-title`}
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className="grid gap-2.5">
<Label htmlFor={`${id}-description`}>Meta-Beschreibung</Label>
<Textarea
id={`${id}-description`}
value={description}
onChange={(event) => setDescription(event.target.value)}
className="min-h-20"
/>
</div>
</div>
{/* Live search result preview */}
<div aria-label="Vorschau des Suchergebnisses" role="group" className="rounded-xl bg-white p-4 text-black">
<p className="text-xs text-ink-600">since3.de</p>
<p className="mt-1 text-lg leading-snug font-medium underline-offset-2">{truncate(title, 60) || "Seitentitel"}</p>
<p className="mt-1 text-sm text-ink-700">
{truncate(description, 160) || "Meta-Beschreibung"}
</p>
</div>
<div>
<Button type="submit" variant="solid">
Snippet prüfen
</Button>
</div>
<ToolOutput lines={lines} placeholder="Ergebnis erscheint hier." />
</form>
)
}

View file

@ -0,0 +1,81 @@
"use client"
import { useEffect, useState, useSyncExternalStore } from "react"
import { play } from "@/lib/sound"
import { cn } from "@/lib/utils"
import type { OutputLine, OutputTone } from "@/modules/landingPage/lib/tools"
// Slow enough that each typewriter tick is audible on its own
const LINE_DELAY_MS = 55
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
const toneClass: Record<OutputTone, string> = {
default: "text-white",
muted: "text-ink-400",
success: "text-signal-success",
error: "text-signal-error",
}
function subscribeReducedMotion(onChange: () => void) {
const query = window.matchMedia(REDUCED_MOTION)
query.addEventListener("change", onChange)
return () => query.removeEventListener("change", onChange)
}
function usePrefersReducedMotion() {
return useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_MOTION).matches,
() => false
)
}
/** Terminal-style output that appears line by line (motion #7). */
export function ToolOutput({
lines,
placeholder,
}: {
lines: OutputLine[] | null
placeholder: string
}) {
const reduced = usePrefersReducedMotion()
const [reveal, setReveal] = useState({ lines, visible: 0 })
// A new result restarts the reveal (state adjusted during render, not in an effect)
if (reveal.lines !== lines) setReveal({ lines, visible: 0 })
useEffect(() => {
if (!lines || reduced) return
let shown = 0
const interval = window.setInterval(() => {
shown += 1
play("tick")
setReveal({ lines, visible: shown })
if (shown >= lines.length) window.clearInterval(interval)
}, LINE_DELAY_MS)
return () => window.clearInterval(interval)
}, [lines, reduced])
const visible = reduced ? (lines?.length ?? 0) : reveal.visible
return (
<div className="min-h-44 overflow-x-auto rounded-xl border border-white/10 bg-black/40 p-4 font-mono text-label leading-relaxed">
{/* Announce the complete result once, not every single line */}
<p className="sr-only" aria-live="polite">
{lines ? lines.slice(1).map((line) => line.text).join(". ") : ""}
</p>
{lines ? (
<pre aria-hidden="true" className="whitespace-pre">
{lines.slice(0, visible).map((line, index) => (
<span key={index} className={cn("block", toneClass[line.tone ?? "default"])}>
{line.text}
</span>
))}
</pre>
) : (
<p className="text-ink-400">{placeholder}</p>
)}
</div>
)
}

View file

@ -0,0 +1,94 @@
"use client"
import { useId, useRef, useState } from "react"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
import { ContrastTool } from "./contrast-tool"
import { SnippetTool } from "./snippet-tool"
const TOOLS = [
{ key: "kontrast", label: "Kontrast-Check", Component: ContrastTool },
{ key: "snippet", label: "Snippet-Check", Component: SnippetTool },
] as const
type ToolKey = (typeof TOOLS)[number]["key"]
/** Glass panel hosting the live tools, with accessible tabs. */
export function ToolPanel() {
const id = useId()
const [active, setActive] = useState<ToolKey>("kontrast")
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({})
const select = (key: ToolKey) => {
if (key === active) return
setActive(key)
}
// Arrow keys move between tabs (WAI-ARIA tabs pattern)
const onKeyDown = (event: React.KeyboardEvent) => {
const index = TOOLS.findIndex((tool) => tool.key === active)
const offset = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0
if (!offset) return
event.preventDefault()
const next = TOOLS[(index + offset + TOOLS.length) % TOOLS.length]
select(next.key)
tabRefs.current[next.key]?.focus()
}
const ActiveTool = TOOLS.find((tool) => tool.key === active)!.Component
return (
<div className="glass p-4 sm:p-8">
<div
role="tablist"
aria-label="Werkzeuge"
onKeyDown={onKeyDown}
className="flex w-fit gap-1 rounded-full border border-white/10 p-1"
>
{TOOLS.map((tool) => {
const selected = tool.key === active
return (
<button
key={tool.key}
ref={(node) => {
tabRefs.current[tool.key] = node
}}
role="tab"
type="button"
id={`${id}-tab-${tool.key}`}
aria-selected={selected}
aria-controls={`${id}-panel`}
tabIndex={selected ? 0 : -1}
onClick={() => select(tool.key)}
className={cn(
"relative rounded-full px-4 py-2 font-mono text-label transition-colors duration-200",
selected ? "text-black" : "text-ink-300 hover:text-white"
)}
>
{selected ? (
<motion.span
layoutId={`${id}-indicator`}
className="absolute inset-0 rounded-full bg-white"
transition={{ type: "spring", duration: 0.35, bounce: 0.15 }}
/>
) : null}
<span className="relative">{tool.label}</span>
</button>
)
})}
</div>
<div
role="tabpanel"
id={`${id}-panel`}
aria-labelledby={`${id}-tab-${active}`}
className="mt-8"
>
{/* key resets the tool state when switching tabs */}
<ActiveTool key={active} />
</div>
</div>
)
}

View file

@ -0,0 +1,42 @@
import { aboutStatement, services } from "@/modules/landingPage/data/content"
import { AboutSection } from "@/modules/landingPage/ui/components/about-section"
import { AiVisibilitySection } from "@/modules/landingPage/ui/components/ai-visibility-section"
import { ContactSection } from "@/modules/landingPage/ui/components/contact-section"
import { HeroSection } from "@/modules/landingPage/ui/components/hero/hero-section"
import { MarqueeBand } from "@/modules/landingPage/ui/components/marquee-band"
import { PricingSection } from "@/modules/landingPage/ui/components/pricing/pricing-section"
import { ProcessSection } from "@/modules/landingPage/ui/components/process-section"
import { ProjectsShowcase } from "@/modules/landingPage/ui/components/projects/projects-showcase"
import { ScrollStatement } from "@/modules/landingPage/ui/components/scroll-statement"
import { ServicesSection } from "@/modules/landingPage/ui/components/services-section"
import { StatsSection } from "@/modules/landingPage/ui/components/stats-section"
import { ToolsSection } from "@/modules/landingPage/ui/components/tools-section"
// Repeated so one copy of the band is always wider than the viewport
const marqueeItems = [...services, ...services].map((service) => service.title)
export function LandingPageView() {
return (
<>
<HeroSection />
<MarqueeBand items={marqueeItems} />
<section aria-label="Wer wir sind" data-theme="dark" className="py-section">
<div className="container-site grid gap-8 lg:grid-cols-12">
<p className="font-mono text-label text-fg-muted lg:col-span-3">(Wer wir sind)</p>
<ScrollStatement text={aboutStatement} className="type-statement lg:col-span-9" />
</div>
</section>
<ProjectsShowcase />
<ServicesSection />
<StatsSection />
<ToolsSection />
<ProcessSection />
<PricingSection />
<AboutSection />
<AiVisibilitySection />
<ContactSection />
</>
)
}

View file

@ -0,0 +1,40 @@
// Site-wide facts used by header, footer, metadata and the contact section.
export const site = {
name: "since3",
url: "https://since3.de",
description:
"since3 ist eine Digitalagentur aus Steinfurt. Wir entwickeln Websites, Software und KI-Automatisierungen für Unternehmen im Münsterland und darüber hinaus.",
email: "info@since3.de",
phone: "+49 176 47601638",
phoneHref: "tel:+4917647601638",
city: "Steinfurt",
} as const
export const mainNav = [
{ label: "Projekte", href: "#projekte" },
{ label: "Leistungen", href: "#leistungen" },
{ label: "Tools", href: "#tools" },
{ label: "Prozess", href: "#prozess" },
{ label: "Preise", href: "#preise" },
{ label: "Über uns", href: "#ueber-uns" },
] as const
// Regional terms for local SEO.
// TODO: turn these into links once the regional landing pages exist.
export const regions = [
"Steinfurt",
"Münster",
"Rheine",
"Emsdetten",
"Greven",
"Ochtrup",
"Borghorst",
"Burgsteinfurt",
] as const
export const legalNav = [
{ label: "Impressum", href: "/impressum" },
{ label: "Datenschutz", href: "/datenschutz" },
{ label: "Barrierefreiheit", href: "/barrierefreiheit" },
] as const

View file

@ -0,0 +1,25 @@
// Shared contract between the inline intro script, the preloader and
// everything that waits for the intro to finish (smooth scroll, hero shader).
export const INTRO_CLASS = "is-intro"
export const INTRO_END_EVENT = "since3:intro-end"
export const INTRO_STORAGE_KEY = "since3:intro-seen"
// Runs synchronously before the first paint. Plays the intro once per session
// and never for people who prefer reduced motion. The timeout is a failsafe in
// case the JavaScript bundle never arrives.
export const introScript = `(function(){try{var d=document.documentElement;if(!sessionStorage.getItem("${INTRO_STORAGE_KEY}")&&!matchMedia("(prefers-reduced-motion: reduce)").matches){d.classList.add("${INTRO_CLASS}");setTimeout(function(){d.classList.remove("${INTRO_CLASS}")},8000)}}catch(e){}})()`
export function isIntroRunning() {
return document.documentElement.classList.contains(INTRO_CLASS)
}
/** Calls `callback` once the intro is over (immediately if there is none). */
export function onIntroEnd(callback: () => void) {
if (!isIntroRunning()) {
callback()
return () => {}
}
window.addEventListener(INTRO_END_EVENT, callback, { once: true })
return () => window.removeEventListener(INTRO_END_EVENT, callback)
}

View file

@ -0,0 +1,114 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
type CursorMode = "hidden" | "default" | "hover" | "label"
const INTERACTIVE = "a, button, summary, label, [role='tab'], [data-cursor]"
const FOLLOW = 0.22
/**
* A blend-mode dot that inverts whatever is below it. It grows over
* interactive elements and shows a label over elements with `data-cursor`.
*/
export function CustomCursor() {
const ref = useRef<HTMLDivElement>(null)
const [mode, setMode] = useState<CursorMode>("hidden")
const [label, setLabel] = useState("")
const [pressed, setPressed] = useState(false)
useEffect(() => {
const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
if (!fine || reduced) return
const root = document.documentElement
root.classList.add("has-cursor")
let x = -100
let y = -100
let targetX = -100
let targetY = -100
let frame = 0
const tick = () => {
x += (targetX - x) * FOLLOW
y += (targetY - y) * FOLLOW
if (ref.current) ref.current.style.transform = `translate3d(${x}px, ${y}px, 0)`
const settled = Math.abs(targetX - x) < 0.1 && Math.abs(targetY - y) < 0.1
frame = settled ? 0 : requestAnimationFrame(tick)
}
const onMove = (event: PointerEvent) => {
targetX = event.clientX
targetY = event.clientY
// First movement after load or after leaving the window
setMode((current) => (current === "hidden" ? "default" : current))
if (!frame) frame = requestAnimationFrame(tick)
}
const onOver = (event: PointerEvent) => {
const target = (event.target as Element | null)?.closest<HTMLElement>(INTERACTIVE)
if (target?.dataset.cursor) {
setLabel(target.dataset.cursor)
setMode("label")
} else {
setMode(target ? "hover" : "default")
}
}
const onLeaveWindow = () => setMode("hidden")
const onDown = () => setPressed(true)
const onUp = () => setPressed(false)
window.addEventListener("pointermove", onMove, { passive: true })
document.addEventListener("pointerover", onOver)
document.documentElement.addEventListener("pointerleave", onLeaveWindow)
window.addEventListener("pointerdown", onDown)
window.addEventListener("pointerup", onUp)
return () => {
root.classList.remove("has-cursor")
cancelAnimationFrame(frame)
window.removeEventListener("pointermove", onMove)
document.removeEventListener("pointerover", onOver)
document.documentElement.removeEventListener("pointerleave", onLeaveWindow)
window.removeEventListener("pointerdown", onDown)
window.removeEventListener("pointerup", onUp)
}
}, [])
const scale = {
hidden: 0,
default: 0.12,
hover: 0.5,
label: 1,
}[mode]
return (
<div
ref={ref}
aria-hidden="true"
className={cn(
"pointer-events-none fixed top-0 left-0 z-95 will-change-transform",
mode !== "label" && "mix-blend-difference"
)}
>
<div
style={{ scale: pressed ? scale * 0.8 : scale }}
className="-mt-14 -ml-14 grid size-28 place-items-center rounded-full bg-white transition-[scale] duration-500 ease-out-expo"
>
<span
className={cn(
"font-mono text-label text-black transition-opacity duration-200",
mode === "label" ? "opacity-100" : "opacity-0"
)}
>
{label}
</span>
</div>
</div>
)
}

View file

@ -0,0 +1,13 @@
/**
* Script that runs during HTML parsing, before the first paint.
* On the client it is rendered inert so React does not warn about script tags.
*/
export function InlineScript({ html }: { html: string }) {
return (
<script
type={typeof window === "undefined" ? "text/javascript" : "text/plain"}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}

View file

@ -0,0 +1,30 @@
"use client"
import { useSyncExternalStore } from "react"
const formatter = new Intl.DateTimeFormat("de-DE", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZone: "Europe/Berlin",
})
function subscribe(onChange: () => void) {
const interval = window.setInterval(onChange, 1000)
return () => window.clearInterval(interval)
}
/** Local time in Steinfurt, ticking every second. */
export function LiveClock({ className }: { className?: string }) {
const time = useSyncExternalStore(
subscribe,
() => formatter.format(new Date()),
() => "--:--:--"
)
return (
<time className={className} suppressHydrationWarning>
{time}
</time>
)
}

View file

@ -0,0 +1,55 @@
"use client"
import { useEffect, useRef } from "react"
import { cn } from "@/lib/utils"
/** Pulls its child towards the pointer while hovered. */
export function Magnetic({
children,
strength = 0.35,
className,
}: {
children: React.ReactNode
strength?: number
className?: string
}) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
if (!fine || reduced) return
const onMove = (event: PointerEvent) => {
const rect = el.getBoundingClientRect()
const dx = event.clientX - (rect.left + rect.width / 2)
const dy = event.clientY - (rect.top + rect.height / 2)
el.style.translate = `${dx * strength}px ${dy * strength}px`
}
const onLeave = () => {
el.style.translate = "0px 0px"
}
el.addEventListener("pointermove", onMove)
el.addEventListener("pointerleave", onLeave)
return () => {
el.removeEventListener("pointermove", onMove)
el.removeEventListener("pointerleave", onLeave)
}
}, [strength])
return (
<div
ref={ref}
className={cn(
"inline-block transition-[translate] duration-700 ease-out-expo",
className
)}
>
{children}
</div>
)
}

View file

@ -0,0 +1,105 @@
"use client"
import { useEffect, useRef } from "react"
import {
INTRO_CLASS,
INTRO_END_EVENT,
INTRO_STORAGE_KEY,
isIntroRunning,
} from "@/modules/shared/lib/intro"
const COUNT_DURATION_MS = 1600
const CURTAIN_DURATION_MS = 1000
// The hero starts animating while the curtain is still moving up
const HERO_START_OFFSET_MS = 250
// Fast start, dramatic hold near 100
function easeCount(t: number) {
return t < 0.7 ? (t / 0.7) * 0.86 : 0.86 + (1 - Math.pow(1 - (t - 0.7) / 0.3, 3)) * 0.14
}
export function Preloader() {
const rootRef = useRef<HTMLDivElement>(null)
const countRef = useRef<HTMLSpanElement>(null)
const barRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const root = rootRef.current
if (!root || !isIntroRunning()) return
const timers: number[] = []
let frame = 0
const start = performance.now()
const finish = () => {
root.dataset.state = "leaving"
timers.push(
window.setTimeout(() => {
document.documentElement.classList.remove(INTRO_CLASS)
try {
sessionStorage.setItem(INTRO_STORAGE_KEY, "1")
} catch {
// Storage blocked: the intro simply plays again next time
}
window.dispatchEvent(new Event(INTRO_END_EVENT))
}, HERO_START_OFFSET_MS),
window.setTimeout(() => {
root.dataset.state = "done"
}, CURTAIN_DURATION_MS)
)
}
const tick = (now: number) => {
const t = Math.min((now - start) / COUNT_DURATION_MS, 1)
const value = easeCount(t)
if (countRef.current) {
countRef.current.textContent = String(Math.round(value * 100)).padStart(3, "0")
}
if (barRef.current) barRef.current.style.scale = `${value} 1`
if (t < 1) frame = requestAnimationFrame(tick)
else finish()
}
frame = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(frame)
timers.forEach(window.clearTimeout)
}
}, [])
return (
<div
ref={rootRef}
aria-hidden="true"
className="preloader fixed inset-0 z-100 flex-col justify-between bg-black p-gutter text-white"
>
<div className="flex items-start justify-between font-mono text-label text-ink-400">
<span>Digitalagentur</span>
<span>Steinfurt, DE</span>
</div>
<p className="self-center font-mono text-2xl sm:text-4xl">
<span className="text-ink-500">&lt;</span>since3
<span className="text-ink-500">&gt;</span>
</p>
<div className="flex flex-col gap-4">
<div className="flex items-end justify-between">
<span className="font-mono text-label text-ink-400">Lädt Erlebnis</span>
<span
ref={countRef}
className="text-mega leading-none tabular-nums type-poster"
>
000
</span>
</div>
<div className="h-px w-full bg-white/15">
<div ref={barRef} className="h-full origin-left bg-white [scale:0_1]" />
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,62 @@
"use client"
import { useRef } from "react"
import { cn } from "@/lib/utils"
const GLYPHS = "!<>-_\\/[]{}=+*^?#01"
const DURATION_MS = 420
/** Link whose label decodes itself on hover, like terminal output. */
export function ScrambleLink({
href,
children,
className,
onClick,
}: {
href: string
children: string
className?: string
onClick?: () => void
}) {
const textRef = useRef<HTMLSpanElement>(null)
const frameRef = useRef(0)
const scramble = () => {
const el = textRef.current
if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
cancelAnimationFrame(frameRef.current)
const start = performance.now()
const tick = (now: number) => {
const progress = Math.min((now - start) / DURATION_MS, 1)
const revealed = Math.floor(progress * children.length)
el.textContent = children
.split("")
.map((char, index) =>
index < revealed || char === " "
? char
: GLYPHS[Math.floor(Math.random() * GLYPHS.length)]
)
.join("")
if (progress < 1) frameRef.current = requestAnimationFrame(tick)
}
frameRef.current = requestAnimationFrame(tick)
}
return (
<a
href={href}
onPointerEnter={scramble}
onFocus={scramble}
onClick={onClick}
aria-label={children}
className={cn("inline-block", className)}
>
{/* Fixed width per character so the scramble never shifts the layout */}
<span ref={textRef} aria-hidden="true" className="font-mono">
{children}
</span>
</a>
)
}

View file

@ -0,0 +1,91 @@
import Image from "next/image"
import Link from "next/link"
import { ArrowUp } from "lucide-react"
import { legalNav, mainNav, regions, site } from "@/modules/shared/config/site"
import { LiveClock } from "./live-clock"
import { Magnetic } from "./magnetic"
export function SiteFooter() {
const year = new Date().getFullYear()
return (
<footer data-theme="dark" className="relative overflow-clip pt-24">
<div className="container-site grid gap-12 font-mono text-label md:grid-cols-12">
<div className="flex flex-col gap-2 md:col-span-4">
<p className="text-fg-muted">Kontakt</p>
<a href={`mailto:${site.email}`} className="w-fit hover:underline">
{site.email}
</a>
<a href={site.phoneHref} className="w-fit hover:underline">
{site.phone}
</a>
<p className="mt-4 text-fg-muted">
{site.city} <LiveClock className="text-fg tabular-nums" />
</p>
</div>
<nav aria-label="Seitenbereiche" className="md:col-span-2">
<p className="text-fg-muted">Seite</p>
<ul className="mt-2 flex flex-col gap-2">
{mainNav.map((item) => (
<li key={item.href}>
<a href={item.href} className="hover:underline">
{item.label}
</a>
</li>
))}
</ul>
</nav>
<div className="md:col-span-4">
<p className="text-fg-muted">Für Unternehmen in</p>
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
{regions.map((region) => (
<li key={region}>{region}</li>
))}
</ul>
</div>
<div className="flex md:col-span-2 md:justify-end">
<Magnetic strength={0.4}>
<a
href="#top"
className="grid size-20 place-items-center rounded-full border border-line transition-colors duration-300 hover:bg-fg hover:text-bg"
>
<ArrowUp className="size-5" aria-hidden />
<span className="sr-only">Nach oben</span>
</a>
</Magnetic>
</div>
</div>
<div className="container-site mt-20 flex flex-col gap-4 border-t border-line pt-6 font-mono text-label text-fg-muted sm:flex-row sm:justify-between">
<p>© {year} since3. Gebaut in {site.city}.</p>
<nav aria-label="Rechtliches">
<ul className="flex flex-wrap gap-x-6 gap-y-2">
{legalNav.map((item) => (
<li key={item.href}>
<Link href={item.href} prefetch={false} className="hover:text-fg hover:underline">
{item.label}
</Link>
</li>
))}
</ul>
</nav>
</div>
{/* The wordmark as the last, full-width statement of the page */}
<div className="reveal container-site pt-12 pb-6">
<Image
src="/pictures/since3-logo-weiss.svg"
alt=""
width={1617}
height={332}
className="h-auto w-full"
/>
</div>
</footer>
)
}

View file

@ -0,0 +1,159 @@
"use client"
import { useEffect, useRef, useState } from "react"
import Image from "next/image"
import { play } from "@/lib/sound"
import { cn } from "@/lib/utils"
import { mainNav, site } from "@/modules/shared/config/site"
import { LiveClock } from "./live-clock"
import { ScrambleLink } from "./scramble-link"
import { SoundToggle } from "./sound-toggle"
/**
* Fixed header in `mix-blend-mode: difference`, so it stays readable on black
* and on white sections. Hides while scrolling down, returns when scrolling up.
*/
export function SiteHeader() {
const [hidden, setHidden] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const firstLinkRef = useRef<HTMLAnchorElement>(null)
useEffect(() => {
let lastY = window.scrollY
const onScroll = () => {
const y = window.scrollY
if (Math.abs(y - lastY) < 6) return
setHidden(y > lastY && y > 160)
lastY = y
}
window.addEventListener("scroll", onScroll, { passive: true })
return () => window.removeEventListener("scroll", onScroll)
}, [])
useEffect(() => {
if (!menuOpen) return
firstLinkRef.current?.focus()
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setMenuOpen(false)
}
document.documentElement.style.overflow = "hidden"
window.addEventListener("keydown", onKey)
return () => {
document.documentElement.style.overflow = ""
window.removeEventListener("keydown", onKey)
}
}, [menuOpen])
const close = () => setMenuOpen(false)
return (
<>
<header
data-hidden={hidden && !menuOpen}
className="site-header fixed inset-x-0 top-0 z-85"
>
<nav
aria-label="Hauptnavigation"
className="container-site grid h-20 grid-cols-[1fr_auto] items-center gap-6 md:h-24 lg:grid-cols-[1fr_auto_1fr]"
>
<a href="#top" aria-label="since3 – nach oben" className="w-fit">
<Image
src="/pictures/since3-logo-weiss.svg"
alt=""
width={1617}
height={332}
loading="eager"
className="h-5 w-auto md:h-6"
/>
</a>
<ul className="hidden items-center gap-8 text-label lg:flex">
{mainNav.map((item) => (
<li key={item.href}>
<ScrambleLink href={item.href} className="py-2 opacity-80 hover:opacity-100">
{item.label}
</ScrambleLink>
</li>
))}
</ul>
<div className="flex items-center justify-end gap-2 md:gap-4">
<LiveClock className="hidden font-mono text-label tabular-nums opacity-70 xl:block" />
<SoundToggle />
<a
href="#kontakt"
className="hidden h-10 items-center rounded-full border border-white px-5 text-sm font-medium transition-colors duration-300 hover:bg-white hover:text-black sm:inline-flex"
>
Projekt starten
</a>
<button
type="button"
data-sound="off"
onClick={() => {
play("whoosh")
setMenuOpen((open) => !open)
}}
aria-expanded={menuOpen}
aria-controls="mobile-menu"
className="grid size-10 place-items-center lg:hidden"
>
<span className="sr-only">{menuOpen ? "Menü schließen" : "Menü öffnen"}</span>
<span aria-hidden="true" className="relative block h-3 w-6">
<span
className={cn(
"absolute inset-x-0 top-0 h-0.5 bg-white transition-[translate,rotate] duration-500 ease-in-out-quart",
menuOpen && "translate-y-1.25 rotate-45"
)}
/>
<span
className={cn(
"absolute inset-x-0 bottom-0 h-0.5 bg-white transition-[translate,rotate] duration-500 ease-in-out-quart",
menuOpen && "-translate-y-1.25 -rotate-45"
)}
/>
</span>
</button>
</div>
</nav>
</header>
{/* Outside the header so it does not get blended */}
<div
id="mobile-menu"
data-theme="dark"
inert={!menuOpen}
className={cn(
"fixed inset-0 z-80 flex flex-col justify-end pb-10 transition-[clip-path] duration-700 ease-in-out-quart lg:hidden",
menuOpen ? "[clip-path:inset(0_0_0_0)]" : "[clip-path:inset(0_0_100%_0)]"
)}
>
<nav aria-label="Mobile Navigation" className="container-site">
<ul className="flex flex-col">
{[...mainNav, { label: "Kontakt", href: "#kontakt" }].map((item, index) => (
<li key={item.href} className="overflow-clip border-b border-line">
<a
ref={index === 0 ? firstLinkRef : undefined}
href={item.href}
onClick={close}
style={{ transitionDelay: menuOpen ? `${150 + index * 60}ms` : "0ms" }}
className={cn(
"block py-3 text-display type-poster transition-[translate] duration-700 ease-out-expo",
menuOpen ? "translate-y-0" : "translate-y-full"
)}
>
{item.label}
</a>
</li>
))}
</ul>
<div className="mt-8 flex justify-between font-mono text-label text-fg-muted">
<a href={`mailto:${site.email}`}>{site.email}</a>
<LiveClock className="tabular-nums" />
</div>
</nav>
</div>
</>
)
}

View file

@ -0,0 +1,31 @@
"use client"
import { useEffect } from "react"
import Lenis from "lenis"
import { onIntroEnd } from "@/modules/shared/lib/intro"
/** Inertia scrolling for the whole page. Renders nothing. */
export function SmoothScroll() {
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
const lenis = new Lenis({
autoRaf: true,
lerp: 0.09,
// Same-page anchor links scroll smoothly too
anchors: { offset: 0 },
})
// Hold the page still while the preloader plays
lenis.stop()
const cleanupIntro = onIntroEnd(() => lenis.start())
return () => {
cleanupIntro()
lenis.destroy()
}
}, [])
return null
}

View file

@ -0,0 +1,56 @@
"use client"
import { useEffect } from "react"
import { play } from "@/lib/sound"
const INTERACTIVE =
"a[href], button:not(:disabled), [role='tab'], summary, input[type='checkbox'], input[type='radio'], label:has(input), [data-sound]"
const HOVER_THROTTLE_MS = 60
/**
* Page-wide sound design via event delegation: a tick when the mouse enters
* a link or button, a click when it is activated. Elements with
* `data-sound="off"` play their own sounds (e.g. the sound toggle). Renders nothing.
*/
export function SoundEffects() {
useEffect(() => {
let lastTarget: Element | null = null
let lastHover = 0
const interactiveTarget = (event: Event) => {
const target = (event.target as Element | null)?.closest?.(INTERACTIVE)
return target && target.getAttribute("data-sound") !== "off" ? target : null
}
const onPointerOver = (event: PointerEvent) => {
// Touch has no hover; a tap would otherwise play hover and click at once
if (event.pointerType !== "mouse") return
const target = interactiveTarget(event)
if (target === lastTarget) return
lastTarget = target
if (!target) return
const now = performance.now()
if (now - lastHover < HOVER_THROTTLE_MS) return
lastHover = now
play("hover")
}
const onClick = (event: MouseEvent) => {
const target = interactiveTarget(event)
// A click on a label is forwarded to its input, which plays the sound
if (!target || target.tagName === "LABEL") return
play("click")
}
document.addEventListener("pointerover", onPointerOver)
// Capture phase, so the sound starts before any navigation or scrolling
document.addEventListener("click", onClick, true)
return () => {
document.removeEventListener("pointerover", onPointerOver)
document.removeEventListener("click", onClick, true)
}
}, [])
return null
}

View file

@ -0,0 +1,60 @@
"use client"
import { useSyncExternalStore } from "react"
import { SOUND_CHANGE_EVENT, isEnabled, play, setEnabled } from "@/lib/sound"
import { cn } from "@/lib/utils"
function subscribe(onChange: () => void) {
window.addEventListener(SOUND_CHANGE_EVENT, onChange)
window.addEventListener("storage", onChange)
return () => {
window.removeEventListener(SOUND_CHANGE_EVENT, onChange)
window.removeEventListener("storage", onChange)
}
}
// Each bar gets its own speed so the equalizer looks organic
const BAR_DURATIONS = ["0.9s", "0.65s", "1.1s", "0.8s"]
/** Equalizer bars that dance while sound is on and lie flat while it is off. */
export function SoundToggle({ className }: { className?: string }) {
const enabled = useSyncExternalStore(subscribe, isEnabled, () => false)
const toggle = () => {
const next = !enabled
setEnabled(next)
// Forced, so switching off is still audible as feedback
play(next ? "on" : "off", { force: true })
}
return (
<button
type="button"
data-sound="off"
onClick={toggle}
aria-pressed={enabled}
aria-label={enabled ? "Sound ausschalten" : "Sound einschalten"}
className={cn(
"flex h-10 items-center gap-2.5 rounded-full px-2 font-mono text-label",
className
)}
>
<span aria-hidden="true" className="flex h-3.5 items-end gap-0.5">
{BAR_DURATIONS.map((duration, index) => (
<span
key={index}
style={{ animationDuration: duration, animationDelay: `${index * -0.23}s` }}
className={cn(
"h-full w-0.5 origin-bottom bg-current transition-[scale] duration-300",
enabled ? "animate-equalizer" : "scale-y-25"
)}
/>
))}
</span>
<span aria-hidden="true" className="hidden opacity-80 md:inline">
{enabled ? "Sound an" : "Sound aus"}
</span>
</button>
)
}

96
package-lock.json generated
View file

@ -11,12 +11,15 @@
"@base-ui/react": "^1.8.0",
"class-variance-authority": "^0.7.1",
"cn": "^0.4.0",
"lenis": "^1.3.26",
"lucide-react": "^1.48.0",
"motion": "^13.4.3",
"next": "16.3.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"shadcn": "^4.21.0",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@ -5191,6 +5194,29 @@
"node": ">= 0.6"
}
},
"node_modules/framer-motion": {
"version": "13.4.3",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.4.3.tgz",
"integrity": "sha512-mPQe1GtRHWS30zRGaQAfmlIg0baA0jRhkpbFPT1d+s9bZPjMW6pJSjWM6ArmUEjzMgEm089ZL1wd8aPv1/5DJQ==",
"license": "MIT",
"dependencies": {
"motion-dom": "^13.4.2",
"motion-utils": "^13.3.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@ -6459,6 +6485,37 @@
"node": ">=0.10"
}
},
"node_modules/lenis": {
"version": "1.3.26",
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.26.tgz",
"integrity": "sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==",
"license": "MIT",
"workspaces": [
"packages/*",
"playground",
"playground/*"
],
"funding": {
"type": "github",
"url": "https://github.com/sponsors/darkroomengineering"
},
"peerDependencies": {
"@nuxt/kit": ">=3.0.0",
"react": ">=17.0.0",
"vue": ">=3.0.0"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
},
"react": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -6974,6 +7031,43 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion": {
"version": "13.4.3",
"resolved": "https://registry.npmjs.org/motion/-/motion-13.4.3.tgz",
"integrity": "sha512-Js4rk+VUxSRMKOFT4hOMyMKlxyF11ee9jomiLT3uuwMOU88EoSro0ZEbTYes4m457pqZQ2G3RsLdHQzoEDyBCQ==",
"license": "MIT",
"dependencies": {
"framer-motion": "^13.4.3",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "13.4.2",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.4.2.tgz",
"integrity": "sha512-mY293Iyj4rOQo5sDxUwPYLOKiveLQrJoK1tKfG8V1H3MMGMfan3s2i7NBW18WnD39DD79ogyiL/1fmy22GLG5w==",
"license": "MIT",
"dependencies": {
"motion-utils": "^13.3.0"
}
},
"node_modules/motion-utils": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.3.0.tgz",
"integrity": "sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View file

@ -12,12 +12,15 @@
"@base-ui/react": "^1.8.0",
"class-variance-authority": "^0.7.1",
"cn": "^0.4.0",
"lenis": "^1.3.26",
"lucide-react": "^1.48.0",
"motion": "^13.4.3",
"next": "16.3.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"shadcn": "^4.21.0",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Ebene_1" x="0px" y="0px" viewBox="0 0 1617 332" style="enable-background:new 0 0 1617 332;" xml:space="preserve"><style type="text/css"> .st0{fill:#FFFFFF;}</style><path class="st0" d="M172.1,86l13.3,21.2L69.2,178.5l116.2,71.9l-14.8,20.2L45.5,192.5v-28.3L172.1,86L172.1,86z"></path><path class="st0" d="M306.1,262.5c13.3,0,23.8-2.5,31.5-7.5s11.6-11.7,11.6-20c0-5.3-1-9.8-2.9-13.7c-1.9-3.8-5.8-7.3-11.8-10.4 c-5.9-3.1-15-6.1-27.3-9.2c-12-3-22.4-6.4-31.2-10.3c-8.8-3.9-15.7-9-20.6-15.2c-4.9-6.3-7.3-14.4-7.3-24.4s2.8-18.2,8.3-25.3 c5.5-7.1,13.2-12.5,23.1-16.3s21.4-5.7,34.4-5.7c13.9,0,26.1,1.8,36.5,5.5c10.4,3.7,19.2,8.2,26.4,13.6l-12.2,18.8 c-6.6-4.5-13.8-8.2-21.7-11.1c-7.9-2.9-17.5-4.4-28.6-4.4c-13.6,0-23.3,2.1-29,6.3c-5.7,4.2-8.6,9.7-8.6,16.4 c0,5.1,1.4,9.2,4.2,12.6c2.8,3.4,7.5,6.4,14.2,9.1c6.7,2.7,15.9,5.6,27.7,8.8c11.3,3,21.2,6.5,29.5,10.7c8.4,4.2,15,9.7,19.7,16.5 c4.7,6.8,7.1,15.6,7.1,26.3c0,12.3-3.5,22.2-10.5,29.9c-7,7.6-16.1,13.2-27.2,16.6s-22.9,5.2-35.3,5.2c-15.7,0-29.1-2.3-40.1-6.8 s-20.3-10.1-27.7-16.5l15.8-18.3c6.8,5.6,14.6,10.1,23.5,13.6C286.4,260.7,295.9,262.5,306.1,262.5L306.1,262.5z"></path><path class="st0" d="M532.5,108.2v151.5h48.9v21.9H451.3v-21.9h53.5V130.1H453v-21.9L532.5,108.2L532.5,108.2z M510.4,27 c6.2,0,11.1,1.9,14.7,5.6c3.7,3.7,5.5,8.3,5.5,13.7s-1.8,10.3-5.5,14.1c-3.7,3.8-8.6,5.7-14.7,5.7c-5.8,0-10.6-1.9-14.3-5.7 s-5.6-8.5-5.6-14.1s1.9-9.9,5.6-13.7C499.8,28.9,504.6,27,510.4,27L510.4,27z"></path><path class="st0" d="M642.3,281.6V108.2h23.5l2.1,24c7-8.9,15.8-15.7,26.4-20.5c10.6-4.8,20.9-7.2,30.9-7.2c17,0,29.4,4.5,37.1,13.5 c7.7,9,11.6,21.5,11.6,37.5v125.9h-27.7V176c0-11.9-0.7-21.4-2-28.7s-4.1-12.6-8.2-16c-4.2-3.3-10.7-5-19.4-5 c-6.8,0-13.2,1.5-19.2,4.5c-6,3-11.3,6.7-16,11.1c-4.7,4.4-8.5,8.9-11.6,13.4v126.3H642.3L642.3,281.6z"></path><path class="st0" d="M918.2,261.5c8.7,0,16.8-1.4,24.3-4.3s14.5-6.5,20.8-10.9l13.5,18.1c-7.6,6.5-16.7,11.6-27.3,15.3 s-21.1,5.6-31.3,5.6c-17.2,0-31.9-3.7-44-11.1c-12.1-7.4-21.4-17.8-27.7-31.3c-6.4-13.4-9.5-29.2-9.5-47.3s3.2-32.9,9.6-46.7 c6.4-13.8,15.7-24.6,27.8-32.5c12.1-7.9,26.9-11.8,44.2-11.8c11.1,0,21.5,1.7,31.2,5c9.7,3.3,18.6,8.4,26.8,15.1L963,143.1 c-7.3-5.2-14.7-9-22.1-11.6c-7.4-2.6-15-3.9-22.6-3.9c-14.9,0-27.2,5.6-36.9,16.8c-9.7,11.2-14.5,28.3-14.5,51.2 c0,15.4,2.2,27.9,6.7,37.6c4.5,9.7,10.5,16.9,18.3,21.4C899.5,259.2,908.3,261.5,918.2,261.5L918.2,261.5z"></path><path class="st0" d="M1059.2,204.7c0.6,13.4,3.1,24.3,7.7,32.8c4.6,8.5,10.7,14.8,18.3,18.8c7.6,4,15.8,6,24.7,6s16.9-1.3,24.1-4 s14.6-6.5,22.1-11.7l13,18.3c-8.1,6.4-17.4,11.4-27.7,15c-10.4,3.6-21,5.4-31.9,5.4c-16.8,0-31.1-3.8-43-11.4s-20.9-18.1-27.1-31.7 c-6.2-13.6-9.3-29.3-9.3-47.2s3.1-33.2,9.4-46.8c6.3-13.6,15-24.3,26.3-32.1c11.3-7.8,24.6-11.7,40-11.7s27.2,3.4,37.7,10.3 c10.5,6.9,18.6,16.6,24.3,29.3c5.7,12.7,8.5,27.8,8.5,45.4c0,3-0.1,5.7-0.2,8.3c-0.1,2.6-0.3,4.9-0.5,6.8L1059.2,204.7L1059.2,204.7 z M1106.2,127.3c-12.8,0-23.6,4.6-32.2,13.7c-8.6,9.1-13.5,23.2-14.7,42.3h90.4c-0.3-18.6-4.4-32.5-12.2-41.9 C1129.7,132,1119.3,127.3,1106.2,127.3L1106.2,127.3z"></path><path class="st0" d="M1290.3,51.2c14.3,0,26.6,2.5,37,7.7s18.4,12,24.1,20.6s8.6,18.1,8.6,28.4c0,9.1-2,17.4-6.1,24.9 c-4.1,7.5-9.4,13.6-16.1,18.4c-6.7,4.8-14.1,8-22.1,9.6c8.9,0.7,17.2,3.2,25,7.5c7.8,4.3,14.2,10.5,19.1,18.4s7.4,17.8,7.4,29.6 c0,13.1-3.3,24.8-9.9,35.3c-6.6,10.5-15.8,18.7-27.6,24.8c-11.8,6-25.6,9-41.2,9c-12.6,0-25.2-2.4-37.8-7.2 c-12.6-4.8-23.2-12.5-31.9-23.1l18.6-15.1c6.5,7.6,14.2,13.2,23.1,17c8.9,3.7,18,5.6,27.2,5.6c15.8,0,28.2-4.1,37-12.4 c8.9-8.3,13.3-19.6,13.3-33.8c0-10.8-2.1-19.3-6.2-25.5c-4.1-6.3-9.7-10.7-16.7-13.4c-7-2.7-15-4-24-4h-19.1l3.5-21.9h13.3 c7.3,0,14.3-1.4,20.8-4.2c6.5-2.8,11.9-7.2,16.1-13.1s6.3-13.3,6.3-22.2c0-12.5-4.2-22.1-12.5-28.7s-18.4-9.9-30.3-9.9 c-9.9,0-18.5,1.7-25.8,5.2s-14.8,8.7-22.2,15.7l-15.3-17c9.7-9.1,19.9-15.7,30.8-19.8S1278.8,51.2,1290.3,51.2L1290.3,51.2z"></path><path class="st0" d="M1441.8,86l126.6,78.2v28.3l-125,78.2l-15-20.4l116.2-71.8l-116.2-71.6L1441.8,86L1441.8,86z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1617 332">
<!-- Generator: Adobe Illustrator 29.1.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 142) -->
<path d="M172.13,85.97l13.34,21.24-116.23,71.29,116.23,71.94-14.82,20.25-125.12-78.2v-28.32l126.6-78.2Z"/>
<path d="M306.13,262.46c13.28,0,23.79-2.5,31.53-7.49,7.74-4.99,11.61-11.66,11.61-20,0-5.27-.96-9.82-2.88-13.67-1.92-3.84-5.84-7.3-11.77-10.37-5.93-3.07-15.04-6.15-27.33-9.22-11.96-2.96-22.36-6.39-31.2-10.29-8.84-3.89-15.7-8.97-20.58-15.23-4.89-6.26-7.33-14.38-7.33-24.37s2.77-18.19,8.31-25.27c5.54-7.08,13.25-12.51,23.13-16.3s21.35-5.68,34.41-5.68c13.94,0,26.09,1.84,36.47,5.52,10.37,3.68,19.18,8.21,26.42,13.58l-12.18,18.77c-6.59-4.5-13.83-8.2-21.73-11.11-7.9-2.91-17.45-4.36-28.65-4.36-13.61,0-23.27,2.11-28.98,6.34-5.71,4.23-8.56,9.69-8.56,16.38,0,5.05,1.4,9.25,4.2,12.59,2.8,3.35,7.54,6.37,14.24,9.05,6.69,2.69,15.91,5.63,27.66,8.81,11.3,2.96,21.16,6.53,29.55,10.7,8.4,4.17,14.95,9.66,19.67,16.46,4.72,6.81,7.08,15.59,7.08,26.34,0,12.29-3.51,22.25-10.54,29.88-7.02,7.63-16.11,13.17-27.25,16.63-11.14,3.46-22.91,5.19-35.31,5.19-15.7,0-29.06-2.28-40.09-6.83-11.03-4.55-20.28-10.07-27.74-16.55l15.8-18.27c6.8,5.6,14.62,10.12,23.46,13.58,8.83,3.46,18.36,5.19,28.56,5.19Z"/>
<path d="M532.5,108.2v151.46h48.9v21.9h-130.06v-21.9h53.51v-129.57h-51.86v-21.9h79.52ZM510.44,27.04c6.15,0,11.06,1.87,14.73,5.6,3.68,3.73,5.52,8.29,5.52,13.66s-1.84,10.29-5.52,14.08c-3.68,3.79-8.59,5.68-14.73,5.68-5.82,0-10.59-1.89-14.32-5.68-3.73-3.79-5.6-8.48-5.6-14.08s1.86-9.93,5.6-13.66c3.73-3.73,8.5-5.6,14.32-5.6Z"/>
<path d="M642.31,281.56V108.2h23.54l2.14,24.04c7.02-8.89,15.83-15.72,26.42-20.5,10.59-4.77,20.88-7.16,30.87-7.16,17.01,0,29.39,4.5,37.12,13.5,7.74,9,11.61,21.51,11.61,37.54v125.94h-27.66v-105.53c0-11.85-.66-21.43-1.98-28.73-1.32-7.3-4.06-12.62-8.23-15.97-4.17-3.35-10.65-5.02-19.43-5.02-6.81,0-13.2,1.48-19.18,4.45-5.98,2.96-11.31,6.67-15.97,11.11-4.67,4.44-8.54,8.92-11.61,13.42v126.27h-27.66Z"/>
<path d="M918.23,261.47c8.67,0,16.76-1.43,24.28-4.28,7.52-2.85,14.46-6.48,20.83-10.87l13.5,18.11c-7.57,6.48-16.68,11.58-27.33,15.31-10.65,3.73-21.07,5.6-31.28,5.6-17.23,0-31.91-3.7-44.04-11.11-12.13-7.41-21.38-17.83-27.74-31.28-6.37-13.44-9.55-29.22-9.55-47.33s3.21-32.9,9.63-46.67c6.42-13.77,15.69-24.61,27.82-32.52,12.13-7.9,26.86-11.85,44.21-11.85,11.08,0,21.48,1.67,31.2,5.02,9.71,3.35,18.63,8.37,26.75,15.06l-13.5,18.44c-7.35-5.16-14.73-9.03-22.14-11.61-7.41-2.58-14.96-3.87-22.64-3.87-14.93,0-27.22,5.6-36.88,16.79-9.66,11.19-14.49,28.26-14.49,51.2,0,15.37,2.22,27.91,6.67,37.62,4.45,9.71,10.54,16.85,18.27,21.4,7.74,4.56,16.54,6.83,26.42,6.83Z"/>
<path d="M1059.15,204.67c.55,13.39,3.13,24.34,7.74,32.84,4.61,8.51,10.7,14.76,18.27,18.77,7.57,4.01,15.8,6.01,24.69,6.01s16.93-1.32,24.12-3.95c7.19-2.63,14.57-6.53,22.14-11.69l13.01,18.27c-8.12,6.37-17.37,11.36-27.74,14.98-10.37,3.62-20.99,5.43-31.86,5.43-16.79,0-31.12-3.79-42.97-11.36s-20.88-18.13-27.08-31.69c-6.2-13.55-9.3-29.28-9.3-47.17s3.13-33.15,9.38-46.76c6.26-13.61,15.04-24.31,26.34-32.1,11.3-7.79,24.64-11.69,40.01-11.69s27.17,3.43,37.7,10.29c10.54,6.86,18.63,16.63,24.28,29.3,5.65,12.68,8.48,27.8,8.48,45.36,0,2.96-.06,5.74-.16,8.31-.11,2.58-.28,4.86-.49,6.83h-116.56ZM1106.24,127.3c-12.84,0-23.57,4.56-32.19,13.66-8.62,9.11-13.53,23.21-14.73,42.31h90.38c-.33-18.55-4.39-32.51-12.18-41.9-7.79-9.38-18.22-14.08-31.28-14.08Z"/>
<path d="M1290.29,51.24c14.27,0,26.59,2.55,36.96,7.66s18.41,11.96,24.12,20.58c5.71,8.62,8.56,18.08,8.56,28.4,0,9.11-2.03,17.4-6.09,24.86-4.06,7.46-9.44,13.61-16.13,18.44-6.7,4.83-14.05,8.01-22.06,9.55,8.89.66,17.23,3.16,25.02,7.49,7.79,4.34,14.16,10.48,19.1,18.44,4.94,7.96,7.41,17.81,7.41,29.55,0,13.06-3.29,24.83-9.88,35.31-6.58,10.48-15.78,18.74-27.58,24.78-11.8,6.04-25.55,9.05-41.24,9.05-12.62,0-25.22-2.42-37.78-7.24-12.57-4.83-23.19-12.51-31.86-23.05l18.6-15.15c6.47,7.57,14.18,13.23,23.13,16.96,8.94,3.73,18.03,5.6,27.25,5.6,15.8,0,28.15-4.14,37.04-12.43,8.89-8.29,13.34-19.56,13.34-33.83,0-10.75-2.06-19.26-6.17-25.52-4.12-6.26-9.69-10.73-16.71-13.42-7.03-2.69-15.04-4.03-24.04-4.03h-19.1l3.46-21.9h13.33c7.35,0,14.29-1.4,20.83-4.2,6.53-2.8,11.88-7.16,16.05-13.09,4.17-5.93,6.26-13.34,6.26-22.23,0-12.51-4.17-22.06-12.51-28.65-8.34-6.59-18.44-9.88-30.29-9.88-9.88,0-18.5,1.73-25.85,5.19-7.35,3.46-14.76,8.7-22.22,15.72l-15.31-16.96c9.66-9.11,19.92-15.72,30.79-19.84,10.87-4.12,22.06-6.17,33.58-6.17Z"/>
<path d="M1441.75,85.97l126.6,78.2v28.32l-124.96,78.2-14.98-20.41,116.23-71.78-116.23-71.62,13.33-20.91Z"/>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB