Merge pull request #1 from Since-3/feature/webddesign
added full design
BIN
app/apple-icon.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
app/favicon.ico
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 4 KiB |
21
app/fonts.ts
Normal 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,
|
||||||
|
});
|
||||||
551
app/globals.css
|
|
@ -2,129 +2,474 @@
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.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 {
|
@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-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--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: 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-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) * 1.4);
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--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 {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--radius: 0.75rem;
|
||||||
--foreground: oklch(0.145 0 0);
|
/* Static light position for the glass edge highlight */
|
||||||
--card: oklch(1 0 0);
|
--light-x: 30%;
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--light-y: 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
/*
|
||||||
--background: oklch(0.145 0 0);
|
* Custom properties resolve where they are declared, so the shadcn mapping
|
||||||
--foreground: oklch(0.985 0 0);
|
* is re-declared on every themed element.
|
||||||
--card: oklch(0.205 0 0);
|
*/
|
||||||
--card-foreground: oklch(0.985 0 0);
|
:root,
|
||||||
--popover: oklch(0.205 0 0);
|
[data-theme] {
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--background: var(--bg);
|
||||||
--primary: oklch(0.922 0 0);
|
--foreground: var(--fg);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--card: var(--bg);
|
||||||
--secondary: oklch(0.269 0 0);
|
--card-foreground: var(--fg);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--popover: var(--bg);
|
||||||
--muted: oklch(0.269 0 0);
|
--popover-foreground: var(--fg);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--primary: var(--fg);
|
||||||
--accent: oklch(0.269 0 0);
|
--primary-foreground: var(--bg);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--secondary: var(--surface);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--secondary-foreground: var(--fg);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--muted: var(--surface);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--muted-foreground: var(--fg-muted);
|
||||||
--ring: oklch(0.556 0 0);
|
--accent: var(--surface);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--accent-foreground: var(--fg);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--destructive: var(--color-signal-error);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--border: var(--line);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--input: var(--color-ink-500);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--ring: var(--fg);
|
||||||
--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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border;
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@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
|
|
@ -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 |
|
|
@ -1,29 +1,69 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
|
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";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
metadataBase: new URL(site.url),
|
||||||
description: "Generated by create next app",
|
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<"/">) {
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
return (
|
return (
|
||||||
|
// The intro script adds a class before hydration
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="de"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
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>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
app/manifest.ts
Normal 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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
|
import { LandingPageView } from "@/modules/landingPage/ui/views/landing-page-view";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return <LandingPageView />;
|
||||||
<div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,36 @@
|
||||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
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(
|
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: {
|
variants: {
|
||||||
variant: {
|
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:
|
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",
|
"border-fg/20 bg-transparent text-fg hover:border-fg/60 aria-expanded:border-fg/60",
|
||||||
secondary:
|
// Only on top of glass surfaces or dark sections
|
||||||
"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",
|
glass:
|
||||||
ghost:
|
"border-white/15 bg-white/10 text-white backdrop-blur-md hover:bg-white/20",
|
||||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
ghost: "border-transparent text-fg hover:bg-fg/5 aria-expanded:bg-fg/5",
|
||||||
destructive:
|
link: "rounded-none border-transparent px-0 text-fg underline-offset-4 hover:underline",
|
||||||
"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",
|
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default:
|
default: "h-11 gap-2 px-5 text-[0.9375rem]",
|
||||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
sm: "h-9 gap-1.5 px-4 text-sm",
|
||||||
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",
|
lg: "h-13 gap-2 px-7 text-base",
|
||||||
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",
|
icon: "size-10",
|
||||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
"icon-sm": "size-8",
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "solid",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -41,7 +38,7 @@ const buttonVariants = cva(
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
variant = "default",
|
variant = "solid",
|
||||||
size = "default",
|
size = "default",
|
||||||
...props
|
...props
|
||||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
}: 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
|
|
@ -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
|
|
@ -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
|
|
@ -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 }
|
||||||
18
components/ui/textarea.tsx
Normal 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
|
|
@ -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)
|
||||||
|
}
|
||||||
16
lib/utils.ts
|
|
@ -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"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
135
modules/landingPage/data/content.ts
Normal 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)}` },
|
||||||
|
]
|
||||||
124
modules/landingPage/data/pricing.ts
Normal 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"]
|
||||||
193
modules/landingPage/forms/contact-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
modules/landingPage/lib/contact-prefill.ts
Normal 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 }))
|
||||||
|
}
|
||||||
152
modules/landingPage/lib/pricing.ts
Normal 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: `
|
||||||
|
}
|
||||||
129
modules/landingPage/lib/tools.ts
Normal 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
|
||||||
|
}
|
||||||
35
modules/landingPage/schema/contact-schema.ts
Normal 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" }
|
||||||
50
modules/landingPage/server/contact-action.ts
Normal 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.",
|
||||||
|
}
|
||||||
|
}
|
||||||
57
modules/landingPage/ui/components/about-section.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
modules/landingPage/ui/components/ai-visibility-section.tsx
Normal 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">> </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>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
modules/landingPage/ui/components/contact-section.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
modules/landingPage/ui/components/count-up.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
modules/landingPage/ui/components/hero/hero-section.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
330
modules/landingPage/ui/components/hero/hero-shader.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
modules/landingPage/ui/components/hero/kinetic-headline.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
modules/landingPage/ui/components/marquee-band.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
376
modules/landingPage/ui/components/pricing/price-calculator.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
modules/landingPage/ui/components/process-section.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
modules/landingPage/ui/components/projects/project-visual.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
modules/landingPage/ui/components/projects/projects-showcase.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
modules/landingPage/ui/components/scroll-statement.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
modules/landingPage/ui/components/services-section.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
modules/landingPage/ui/components/stats-section.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
modules/landingPage/ui/components/tools-section.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
modules/landingPage/ui/components/tools/contrast-tool.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
modules/landingPage/ui/components/tools/snippet-tool.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
modules/landingPage/ui/components/tools/tool-output.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
modules/landingPage/ui/components/tools/tool-panel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
modules/landingPage/ui/views/landing-page-view.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
modules/shared/config/site.ts
Normal 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
|
||||||
25
modules/shared/lib/intro.ts
Normal 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)
|
||||||
|
}
|
||||||
114
modules/shared/ui/components/custom-cursor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
modules/shared/ui/components/inline-script.tsx
Normal 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 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
modules/shared/ui/components/live-clock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
modules/shared/ui/components/magnetic.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
modules/shared/ui/components/preloader.tsx
Normal 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"><</span>since3
|
||||||
|
<span className="text-ink-500">></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>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
modules/shared/ui/components/scramble-link.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
modules/shared/ui/components/site-footer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
159
modules/shared/ui/components/site-header.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
modules/shared/ui/components/smooth-scroll.tsx
Normal 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
|
||||||
|
}
|
||||||
56
modules/shared/ui/components/sound-effects.tsx
Normal 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
|
||||||
|
}
|
||||||
60
modules/shared/ui/components/sound-toggle.tsx
Normal 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
|
|
@ -11,12 +11,15 @@
|
||||||
"@base-ui/react": "^1.8.0",
|
"@base-ui/react": "^1.8.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cn": "^0.4.0",
|
"cn": "^0.4.0",
|
||||||
|
"lenis": "^1.3.26",
|
||||||
"lucide-react": "^1.48.0",
|
"lucide-react": "^1.48.0",
|
||||||
|
"motion": "^13.4.3",
|
||||||
"next": "16.3.6",
|
"next": "16.3.6",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"shadcn": "^4.21.0",
|
"shadcn": "^4.21.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
@ -5191,6 +5194,29 @@
|
||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/fresh": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
|
|
@ -6459,6 +6485,37 @@
|
||||||
"node": ">=0.10"
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
|
|
@ -6974,6 +7031,43 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,15 @@
|
||||||
"@base-ui/react": "^1.8.0",
|
"@base-ui/react": "^1.8.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cn": "^0.4.0",
|
"cn": "^0.4.0",
|
||||||
|
"lenis": "^1.3.26",
|
||||||
"lucide-react": "^1.48.0",
|
"lucide-react": "^1.48.0",
|
||||||
|
"motion": "^13.4.3",
|
||||||
"next": "16.3.6",
|
"next": "16.3.6",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"shadcn": "^4.21.0",
|
"shadcn": "^4.21.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
|
||||||
BIN
public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
1
public/pictures/since3-logo-weiss.svg
Normal 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 |
BIN
public/pictures/since3-logo.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
12
public/pictures/since3-logo.svg
Normal 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 |