mirror of
https://github.com/Since-3/since3-website.git
synced 2026-10-12 20:51:17 +00:00
added full design
This commit is contained in:
parent
c641cfed7e
commit
b2b4a0b076
67 changed files with 4884 additions and 153 deletions
135
modules/landingPage/data/content.ts
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue