feat: rebuild mintel.me as a single-page freelance site

The site now presents one freelance developer on one page: hero, introduction,
four typical cases (developer for a product, make an app reliable, automate a
process, a website) with what you get, how it runs and what it costs, a
process automation section, technology and project fit, how I work, two live
references, questions and contact.

- Hero and closing headline are drawn from running binary digits.
- Each case explains its four steps with an animated drawing; the automation
  section shows example workflows with work travelling between systems; a
  pipeline run shows what a test catches before a release.
- Contact is one email address, kept in parts and assembled in the browser so
  it never appears in the page source (src/domain/email.ts, spec first).
- Imprint and privacy pages added.
- The old site (blog, case studies, contact form, fixed-price pages, video
  rendering and related scripts) is removed. Its URLs redirect to the homepage.
- The deploy smoke test checks the legal pages and the redirects instead of
  the removed case study.
- Dev scripts use docker compose v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 22:33:06 +02:00
co-authored by Claude Opus 5.5
parent 4eb663f42a
commit fa40fefe2f
781 changed files with 4925 additions and 55137 deletions
-52
View File
@@ -1,52 +0,0 @@
"use server";
import { sendEmail } from "../lib/mail/mailer";
import {
getInquiryEmailHtml,
getConfirmationEmailHtml,
} from "../components/ContactForm/EmailTemplates";
export async function sendContactInquiry(data: {
name: string;
email: string;
phone?: string;
role?: string;
companyName: string;
projectType: string;
deadline?: string;
message: string;
isFreeText: boolean;
config?: any;
}) {
try {
// Payload removed, directly send emails
// 2. Send Inquiry to Marc
const inquiryResult = await sendEmail({
subject: `[PROJEKT] ${data.isFreeText ? "DIREKTANFRAGE" : "KONFIGURATION"}: ${data.companyName || data.name}`,
html: getInquiryEmailHtml(data),
replyTo: data.email,
});
if (!inquiryResult.success) {
throw new Error(inquiryResult.error);
}
// 2. Send Confirmation to Customer
await sendEmail({
to: data.email,
subject: `Kopie deiner Anfrage: ${data.companyName || "mintel.me"}`,
html: getConfirmationEmailHtml(data),
});
return { success: true };
} catch (error) {
console.error("Server Action Error:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Unknown error during submission",
};
}
}
-240
View File
@@ -1,240 +0,0 @@
// @ts-nocheck
"use client";
import * as React from "react";
import {
Page as PDFPage,
Text as PDFText,
View as PDFView,
StyleSheet as PDFStyleSheet,
} from "@react-pdf/renderer";
import {
pdfStyles,
Header,
Footer,
FoldingMarks,
DocumentTitle,
SimpleLayout,
} from "@mintel/pdf";
const localStyles = PDFStyleSheet.create({
sectionContainer: {
marginTop: 0,
},
agbSection: {
marginBottom: 20,
},
labelRow: {
flexDirection: "row",
alignItems: "baseline",
marginBottom: 6,
},
monoNumber: {
fontSize: 7,
fontWeight: "bold",
color: "#94a3b8",
letterSpacing: 2,
width: 25,
},
sectionTitle: {
fontSize: 9,
fontWeight: "bold",
color: "#000000",
textTransform: "uppercase",
letterSpacing: 0.5,
},
officialText: {
fontSize: 8,
lineHeight: 1.5,
color: "#334155",
textAlign: "justify",
paddingLeft: 25,
},
});
const AGBSection = ({
index,
title,
children,
}: {
index: string;
title: string;
children: React.ReactNode;
}) => (
<PDFView style={localStyles.agbSection} wrap={false}>
<PDFView style={localStyles.labelRow}>
<PDFText style={localStyles.monoNumber}>{index}</PDFText>
<PDFText style={localStyles.sectionTitle}>{title}</PDFText>
</PDFView>
<PDFText style={localStyles.officialText}>{children}</PDFText>
</PDFView>
);
interface AgbsPDFProps {
footerLogo?: string;
mode?: "estimation" | "full";
}
export const AgbsPDF = ({ footerLogo, mode = "full" }: AgbsPDFProps) => {
const date = new Date().toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
});
const companyData = {
name: "Marc Mintel",
address1: "Georg-Meistermann-Straße 7",
address2: "54586 Schüller",
ustId: "DE367588065",
};
const bankData = {
name: "N26",
bic: "NTSBDEB1XXX",
iban: "DE50 1001 1001 2620 4328 65",
};
const content = (
<>
<DocumentTitle
title="Allgemeine Geschäftsbedingungen"
subLines={[`Stand: ${date}`]}
/>
<PDFView style={localStyles.sectionContainer}>
<AGBSection index="01" title="Geltungsbereich">
Diese Allgemeinen Geschäftsbedingungen gelten für alle Verträge
zwischen Marc Mintel (nachfolgend „Auftragnehmer“) und dem jeweiligen
Kunden (nachfolgend „Auftraggeber“). Abweichende oder ergänzende
Bedingungen des Auftraggebers werden nicht Vertragsbestandteil, auch
wenn ihrer Geltung nicht ausdrücklich widersprochen wird.
</AGBSection>
<AGBSection index="02" title="Vertragsgegenstand">
Der Auftragnehmer erbringt Dienstleistungen im Bereich:
Webentwicklung, technische Umsetzung digitaler Systeme, Funktionen,
Schnittstellen und Automatisierungen sowie Hosting, Betrieb und
Wartung, sofern ausdrücklich vereinbard. Der Auftragnehmer schuldet
ausschließlich die vereinbarte technische Leistung, nicht jedoch einen
wirtschaftlichen Erfolg, bestimmte Umsätze, Conversions, Reichweiten,
Suchmaschinen-Rankings oder rechtliche Ergebnisse.
</AGBSection>
<AGBSection index="03" title="Mitwirkungspflichten des Auftraggebers">
Der Auftraggeber verpflichtet sich, alle zur Leistungserbringung
erforderlichen Inhalte, Informationen, Zugänge und Entscheidungen
rechtzeitig, vollständig und korrekt bereitzustellen. Hierzu zählen
insbesondere Texte, Bilder, Videos, Produktdaten, Freigaben, Feedback,
Zugangsdaten sowie rechtlich erforderliche Inhalte (z. B. Impressum,
DSGVO). Verzögerungen oder Unterlassungen führen zu Verschiebungen
aller Termine ohne Schadensersatzanspruch.
</AGBSection>
<AGBSection index="04" title="Ausführungs- und Bearbeitungszeiten">
Angegebene Bearbeitungszeiten sind unverbindliche Schätzungen, keine
garantierten Fristen. Fixe Termine oder Deadlines gelten nur, wenn sie
ausdrücklich schriftlich als verbindlich vereinbart wurden.
</AGBSection>
<AGBSection index="05" title="Abnahme">
Die Leistung gilt als abgenommen, wenn der Auftraggeber sie produktiv
nutzt oder innerhalb von 7 Tagen nach Bereitstellung keine
wesentlichen Mängel angezeigt werden. Optische Abweichungen,
Geschmacksfragen oder subjektive Einschätzungen stellen keine Mängel
dar.
</AGBSection>
<AGBSection index="06" title="Haftung">
Der Auftragnehmer haftet nur für Schäden, die auf vorsätzlicher oder
grob fahrlässiger Pflichtverletzung beruhen. Eine Haftung für
entgangenen Gewinn, Umsatzausfälle, Datenverlust,
Betriebsunterbrechungen, mittelbare oder Folgeschäden ist
ausgeschlossen, soweit gesetzlich zulässig.
</AGBSection>
<AGBSection index="07" title="Verfügbarkeit & Betrieb">
Bei vereinbartem Hosting oder Betrieb schuldet der Auftragnehmer keine
permanente Verfügbarkeit. Wartungsarbeiten, Updates,
Sicherheitsmaßnahmen oder externe Störungen können zu zeitweisen
Einschränkungen führen und begründen keine Haftungsansprüche.
</AGBSection>
<AGBSection index="07a" title="Betriebs- und Pflegeleistung">
Die Betriebs- und Pflegeleistung umfasst ausschließlich die
Sicherstellung des technischen Betriebs, Wartung, Updates,
Fehlerbehebung der bestehenden Systeme sowie Pflege bestehender
Datensätze ohne Strukturänderung. Nicht Bestandteil sind die
Erstellung neuer Inhalte (Blogartikel, News, Produkte), redaktionelle
Tätigkeiten, strategische Planung oder der Aufbau neuer
Features/Datenmodelle. Leistungen darüber hinaus gelten als
Neuentwicklung.
</AGBSection>
<AGBSection index="08" title="Drittanbieter & externe Systeme">
Der Auftragnehmer übernimmt keine Verantwortung für Leistungen,
Ausfälle oder Änderungen externer Dienste, APIs, Schnittstellen oder
Plattformen Dritter. Eine Funktionsfähigkeit kann nur im Rahmen der
jeweils aktuellen externen Schnittstellen gewährleistet werden.
</AGBSection>
<AGBSection index="09" title="Inhalte & Rechtliches">
Der Auftraggeber ist allein verantwortlich für Inhalte, rechtliche
Konformität (DSGVO, Urheberrecht etc.) sowie bereitgestellte Daten.
Der Auftragnehmer übernimmt keine rechtliche Prüfung.
</AGBSection>
<AGBSection index="10" title="Vergütung & Zahlungsverzug">
Alle Preise netto zzgl. MwSt. Rechnungen sind innerhalb von 7 Tagen
fällig. Bei Zahlungsverzug ist der Auftragnehmer berechtigt,
Leistungen auszusetzen, Systeme offline zu nehmen oder laufende
Arbeiten zu stoppen.
</AGBSection>
<AGBSection index="11" title="Kündigung laufender Leistungen">
Laufende Leistungen (z. B. Hosting & Betrieb) können mit einer Frist
von 4 Wochen zum Monatsende gekündigt werden, sofern nichts anderes
vereinbart ist.
</AGBSection>
<AGBSection index="12" title="Schlussbestimmungen">
Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist
der Sitz des Auftragnehmers. Sollte eine Bestimmung unwirksam sein,
bleibt die Wirksamkeit der übrigen Regelungen unberührt.
</AGBSection>
</PDFView>
</>
);
if (mode === "full") {
return (
<SimpleLayout
companyData={companyData}
bankData={bankData}
footerLogo={footerLogo}
showPageNumber={false}
>
{content}
</SimpleLayout>
);
}
return (
<PDFPage size="A4" style={pdfStyles.page}>
<FoldingMarks />
<Header
icon={""}
showAddress={false}
sender={companyData as any}
recipient={{} as any}
/>
{content}
<Footer
logo={footerLogo}
companyData={companyData}
bankData={bankData}
showDetails={false}
showPageNumber={false}
/>
</PDFPage>
);
};
+4 -1
View File
@@ -1,7 +1,10 @@
"use client";
import React, { useEffect, Suspense } from "react";
import { useSafePathname, useSafeSearchParams } from "./analytics/useSafePathname";
import {
useSafePathname,
useSafeSearchParams,
} from "./analytics/useSafePathname";
import { ScrollDepthTracker } from "./analytics/ScrollDepthTracker";
import { getDefaultAnalytics } from "../utils/analytics";
import { getDefaultErrorTracking } from "../utils/error-tracking";
@@ -1,249 +0,0 @@
import React from 'react';
import Prism from 'prismjs';
import { ComponentShareButton } from './ComponentShareButton';
import { Reveal } from './Reveal';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-jsx';
import 'prismjs/components/prism-tsx';
import 'prismjs/components/prism-docker';
import 'prismjs/components/prism-yaml';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-markup';
import 'prismjs/components/prism-css';
import 'prismjs/components/prism-sql';
import 'prismjs/components/prism-bash';
import 'prismjs/components/prism-markdown';
interface BlockquoteProps {
children: React.ReactNode;
className?: string;
}
export const ArticleBlockquote: React.FC<BlockquoteProps> = ({ children, className = '' }) => {
// Generate a quick stable hash based on content length/chars for ID
const shareId = `blockquote-${Math.random().toString(36).substring(7).toUpperCase()}`;
return (
<Reveal direction="up" delay={0.1}>
<figure
id={shareId}
className={`not-prose my-16 group relative transition-all duration-500 ease-out z-10 ${className}`}
>
<div className="absolute -inset-1 bg-gradient-to-r from-emerald-100/50 to-emerald-50/50 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative p-8 md:p-12">
{/* Subtle top shine */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
{/* Share Button top right */}
<div className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500">
<ComponentShareButton targetId={shareId} title="Zitat" />
</div>
<div className="flex flex-col md:flex-row gap-6 md:gap-8 items-start relative z-10">
<div className="shrink-0">
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center group-hover:scale-110 group-hover:bg-white group-hover:border-emerald-200 transition-all duration-500 shadow-sm relative overflow-hidden">
{/* Small emerald glow effect inside the icon box on hover */}
<div className="absolute inset-0 bg-emerald-500/0 group-hover:bg-emerald-500/5 transition-colors duration-500" />
<svg className="w-6 h-6 md:w-8 md:h-8 text-slate-300 group-hover:text-emerald-500 transition-colors duration-500 relative z-10" viewBox="0 0 24 24" fill="currentColor">
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
</svg>
</div>
</div>
<blockquote className="flex-1 relative">
<div className="text-xl md:text-2xl lg:text-3xl font-serif text-slate-800 italic leading-relaxed md:leading-[1.4] tracking-tight m-0">
{children}
</div>
</blockquote>
</div>
</div>
</figure>
</Reveal>
);
};
interface CodeBlockProps {
code?: string;
children?: React.ReactNode;
language?: string;
showLineNumbers?: boolean;
className?: string;
}
// Language mapping for Prism.js
const prismLanguageMap: Record<string, string> = {
py: 'python',
ts: 'typescript',
tsx: 'tsx',
js: 'javascript',
jsx: 'jsx',
dockerfile: 'docker',
docker: 'docker',
yml: 'yaml',
yaml: 'yaml',
json: 'json',
html: 'markup',
css: 'css',
sql: 'sql',
sh: 'bash',
bash: 'bash',
md: 'markdown',
astro: 'markup', // Fallback for Astro
};
// Highlight code using Prism.js
const highlightCode = (code: string, language: string): { html: string; prismLanguage: string } => {
const prismLanguage = prismLanguageMap[language] || language || 'markup';
try {
const highlighted = Prism.highlight(
code.trim(),
Prism.languages[prismLanguage] || Prism.languages.markup,
prismLanguage,
);
return { html: highlighted, prismLanguage };
} catch (error) {
console.warn('Prism highlighting failed:', error);
return { html: code.trim(), prismLanguage: 'text' };
}
};
export const CodeBlock: React.FC<CodeBlockProps> = ({
code,
children,
language = 'text',
showLineNumbers = false,
className = ''
}) => {
const codeContent = code || (typeof children === 'string' ? children : String(children || '')).trim();
const { html: highlightedCode, prismLanguage } = language !== 'text' ? highlightCode(codeContent, language) : { html: codeContent, prismLanguage: 'text' };
const lines = codeContent.split('\n');
return (
<>
<style dangerouslySetInnerHTML={{ __html: syntaxHighlightingStyles }} />
<div className="relative my-6">
{language !== 'text' && (
<div className="absolute top-3 right-3 text-[10px] font-bold uppercase tracking-widest bg-white text-slate-500 px-2 py-1 rounded-md z-10 border border-slate-100 shadow-sm">
{language}
</div>
)}
<pre
className={`not-prose m-0 p-6 overflow-x-auto overflow-y-auto text-[13px] leading-[1.65] font-mono text-slate-800 hide-scrollbar border border-slate-200 bg-white rounded-2xl ${className} ${showLineNumbers ? 'pl-12' : ''}`}
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", maxHeight: "22rem" }}
>
{showLineNumbers ? (
<div className="relative">
<div className="absolute left-0 top-0 bottom-0 w-8 text-right text-slate-600 select-none pr-3 border-r border-slate-700">
{lines.map((_, i) => (
<div key={i} className="leading-relaxed">{i + 1}</div>
))}
</div>
<div className="pl-10">
<code className={`language-${prismLanguage}`} dangerouslySetInnerHTML={{ __html: highlightedCode }} />
</div>
</div>
) : (
<code className={`language-${prismLanguage}`} dangerouslySetInnerHTML={{ __html: highlightedCode }} />
)}
</pre>
</div>
</>
);
};
export const InlineCode: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => (
<code className={`not-prose bg-white text-slate-800 px-1.5 py-0.5 rounded-md font-mono text-[0.9em] border border-slate-200 ${className}`}>
{children}
</code>
);
// Prism.js syntax highlighting styles (matching FileExample.astro)
const syntaxHighlightingStyles = `
code[class*='language-'],
pre[class*='language-'],
pre:has(code[class*='language-']) {
color: #0f172a;
background: transparent;
text-shadow: none;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 0.8125rem;
line-height: 1.65;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
tab-size: 2;
hyphens: none;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #64748b;
font-style: italic;
}
.token.punctuation {
color: #94a3b8;
}
.token.operator {
color: #64748b;
}
.token.property,
.token.tag,
.token.constant,
.token.symbol,
.token.deleted {
color: #c2410c;
}
.token.boolean,
.token.number {
color: #a16207;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #059669;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #475569;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #7c3aed;
font-weight: 500;
}
.token.function,
.token.class-name {
color: #2563eb;
}
.token.regex,
.token.important,
.token.variable {
color: #db2777;
}
`;
@@ -1,56 +0,0 @@
import React from "react";
interface HeadingProps {
children: React.ReactNode;
className?: string;
id?: string;
}
const getTextContent = (node: React.ReactNode): string => {
if (typeof node === "string") return node;
if (typeof node === "number") return String(node);
if (React.isValidElement(node)) return getTextContent((node.props as any).children);
if (Array.isArray(node)) return node.map(getTextContent).join("");
return "";
};
const charMap: Record<string, string> = { "ä": "ae", "ö": "oe", "ü": "ue" };
const generateId = (children: React.ReactNode): string => {
const text = getTextContent(children);
return text
.toLowerCase()
.replace(/[äöü]/g, (c) => charMap[c] ?? c)
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
};
export const H1: React.FC<HeadingProps> = ({ children, className = "", id }) => (
<h1
id={id || generateId(children)}
className={`not-prose text-3xl md:text-5xl font-bold text-slate-900 mb-8 mt-12 leading-[1.1] tracking-tight font-sans ${className}`}
>
{children}
</h1>
);
export const H2: React.FC<HeadingProps> = ({ children, className = "", id }) => {
const generatedId = id || generateId(children);
return (
<h2
id={generatedId}
className={`not-prose text-2xl md:text-4xl font-bold text-slate-900 mb-6 mt-10 leading-tight tracking-tight font-sans ${className}`}
>
{children}
</h2>
);
};
export const H3: React.FC<HeadingProps> = ({ children, className = "", id }) => (
<h3
id={id || generateId(children)}
className={`not-prose text-xl md:text-2xl font-bold text-slate-900 mb-4 mt-8 leading-snug tracking-tight font-sans ${className}`}
>
{children}
</h3>
);
-24
View File
@@ -1,24 +0,0 @@
import React from 'react';
interface ListProps {
children: React.ReactNode;
className?: string;
}
export const UL: React.FC<ListProps> = ({ children, className = '' }) => (
<ul className={`list-disc list-inside text-slate-700 leading-relaxed mb-4 ml-4 ${className}`}>
{children}
</ul>
);
export const OL: React.FC<ListProps> = ({ children, className = '' }) => (
<ol className={`list-decimal list-inside text-slate-700 leading-relaxed mb-4 ml-4 ${className}`}>
{children}
</ol>
);
export const LI: React.FC<ListProps> = ({ children, className = '' }) => (
<li className={`mb-1 ${className}`}>
{children}
</li>
);
-120
View File
@@ -1,120 +0,0 @@
"use client";
import React from 'react';
import { ComponentShareButton } from './ComponentShareButton';
import { Reveal } from './Reveal';
interface ArticleMemeProps {
template: string;
/** Pipe-delimited captions, e.g. "top text|bottom text" */
captions: string;
className?: string;
}
/**
* Encode a caption string for the memegen.link URL format.
*/
function encodeMemeCaption(text: string): string {
if (!text || text.trim() === '') return '_';
return text
.trim()
.replace(/_/g, "__")
.replace(/-/g, "--")
.replace(/\n/g, "~q")
.replace(/\?/g, "~q")
.replace(/&/g, "~a")
.replace(/%/g, "~p")
.replace(/#/g, "~h")
.replace(/\//g, "~s")
.replace(/\\/g, "~b")
.replace(/<(?:\/?[a-z0-9]+)*>/gi, "")
.replace(/\s+/g, "_")
.replace(/€/g, "Euro") // Replace Euro sign
.replace(/['"„“‚‘]/g, "") // Remove quotes
.replace(/[^a-zA-Z0-9_\-~äöüÄÖÜß.,!]/g, ""); // Remove other special chars
}
const TEMPLATE_MAP: Record<string, string> = {
'drake': 'drake',
'drake hotline bling': 'drake',
'distracted boyfriend': 'db',
'distracted': 'db',
'expanding brain': 'gb',
'expanding': 'gb',
'gb': 'gb',
'this is fine': 'fine',
'fine': 'fine',
'clown': 'gb',
'clown applying makeup': 'gb',
'two buttons': 'ds',
'daily struggle': 'ds',
'ds': 'ds',
'gru': 'gru',
'change my mind': 'cmm',
'always has been': 'ahb',
'uno reverse': 'uno',
'disaster girl': 'disastergirl',
'is this a pigeon': 'pigeon',
'roll safe': 'rollsafe',
'rollsafe': 'rollsafe',
'surprised pikachu': 'pikachu',
'batman slapping robin': 'slap',
'left exit 12': 'exit',
'one does not simply': 'mordor',
'panik kalm panik': 'panik-kalm-panik',
};
function slugifyTemplate(template: string): string {
if (!template) return 'drake';
const normalized = template.toLowerCase().trim();
const validIds = new Set(Object.values(TEMPLATE_MAP));
// Check if it's already a valid known ID
if (validIds.has(normalized)) return normalized;
// Check case-insensitive map
for (const key of Object.keys(TEMPLATE_MAP)) {
if (key === normalized) return TEMPLATE_MAP[key];
}
// Fallback: don't randomly slugify since memegen might 404. Force known good.
return 'drake';
}
function buildMemeUrl(template: string, captions: string[]): string {
const slug = slugifyTemplate(template);
const encoded = captions.map(encodeMemeCaption);
return `https://api.memegen.link/images/${slug}/${encoded.join('/')}.png`;
}
export const ArticleMeme: React.FC<ArticleMemeProps & { image?: string }> = ({ template, captions, image, className = '' }) => {
const captionList = (captions || '').split('|').map(s => s.trim()).filter(Boolean);
const imageUrl = image || buildMemeUrl(template, captionList);
const shareId = `artmeme-${React.useId().replace(/:/g, "")}`;
return (
<Reveal direction="up" delay={0.1}>
<div id={shareId} className={`not-prose max-w-xl mx-auto my-16 mb-24 group relative transition-all duration-500 ease-out z-10 ${className}`}>
{/* Ambient Glow */}
<div className="absolute -inset-1 bg-gradient-to-r from-red-100/30 to-slate-100/30 rounded-[2rem] blur opacity-10 group-hover:opacity-30 transition duration-1000 -z-10" />
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl overflow-hidden shadow-sm shadow-slate-200 group-hover:shadow-md transition-all duration-500 relative">
{/* Share Button */}
<div className="absolute -bottom-14 right-0 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
<ComponentShareButton targetId={shareId} title={`Meme: ${template}`} />
</div>
<img
src={imageUrl}
alt={captionList.length > 0 ? captionList.join(' / ') : template}
className="w-full h-auto block"
loading="lazy"
/>
</div>
</div>
</Reveal>
);
};
@@ -1,28 +0,0 @@
import React from "react";
interface ParagraphProps {
children: React.ReactNode;
className?: string;
}
export const Paragraph: React.FC<ParagraphProps> = ({
children,
className = "",
}) => (
<div
className={`not-prose text-slate-700 font-serif text-lg md:text-xl leading-[1.6] mb-8 ${className}`}
>
{children}
</div>
);
export const LeadParagraph: React.FC<ParagraphProps> = ({
children,
className = "",
}) => (
<div
className={`not-prose text-xl md:text-2xl text-slate-700 font-serif italic leading-snug mb-10 ${className}`}
>
{children}
</div>
);
-88
View File
@@ -1,88 +0,0 @@
import React from 'react';
import { Quote } from 'lucide-react';
import { ComponentShareButton } from './ComponentShareButton';
import { Reveal } from './Reveal';
interface ArticleQuoteProps {
quote: string;
author: string;
role?: string;
source?: string;
sourceUrl?: string;
translated?: boolean;
isCompany?: boolean;
className?: string;
}
export const ArticleQuote: React.FC<ArticleQuoteProps> = ({
quote,
author,
role,
source,
sourceUrl,
translated,
isCompany,
className = '',
}) => {
// Generate a stable ID based on author for sharing
const shareId = `quote-${author.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
return (
<Reveal direction="up" delay={0.1}>
<figure
id={shareId}
className={`not-prose my-16 group relative transition-all duration-500 ease-out z-10 ${className}`}
>
{/* Ambient Background Glow matching the homepage feel */}
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative">
{/* Subtle top shine */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
<div className="p-8 md:p-12 relative z-10 flex flex-col md:flex-row gap-8 md:gap-12 items-start">
{/* Share Button top right */}
<div className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500">
<ComponentShareButton targetId={shareId} title={`Zitat von ${author}`} />
</div>
{/* Author Meta (Left Rail) */}
<div className="md:w-1/3 flex flex-row md:flex-col items-center md:items-start gap-4 md:gap-6 shrink-0 pt-2">
{isCompany ? (
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center shrink-0 group-hover:scale-110 group-hover:bg-white transition-transform duration-500">
<svg className="w-6 h-6 md:w-8 md:h-8 text-slate-700" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
</div>
) : (
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center shrink-0 group-hover:scale-110 group-hover:bg-white transition-transform duration-500">
<Quote className="w-5 h-5 md:w-6 md:h-6 text-slate-400" />
</div>
)}
<div className="flex flex-col gap-2 mt-2 w-full">
{translated && (
<div className="inline-flex w-fit px-2 py-0.5 border border-slate-200 rounded-md text-[9px] font-mono text-slate-500 uppercase tracking-widest bg-slate-50">Übersetzt</div>
)}
{sourceUrl ? (
<a href={sourceUrl} target="_blank" rel="noreferrer" className="inline-flex w-fit items-center gap-1 text-[10px] font-mono font-bold uppercase tracking-widest text-blue-600 hover:text-blue-800 transition-colors group/link mt-1">
{source || 'Source'} <span className="group-hover/link:translate-x-0.5 transition-transform">&rarr;</span>
</a>
) : (
source && <span className="text-[10px] font-mono uppercase tracking-widest text-slate-400 mt-1">{source}</span>
)}
</div>
</div>
{/* Quote Content (Right Rail) */}
<blockquote className="md:w-3/4 border-l-[3px] border-emerald-500/20 pl-6 md:pl-10 py-1 relative mt-6 md:mt-0">
<Quote className="absolute -top-4 -left-4 w-12 h-12 text-slate-100 -z-10 rotate-180 opacity-50 pointer-events-none" />
<div className="text-xl md:text-3xl font-serif text-slate-800 leading-[1.4] m-0 italic [&>p]:inline [&>ul]:m-0 [&>ul]:list-none [&>ul>li]:m-0 [&>ul>li]:p-0">
{quote}
</div>
</blockquote>
</div>
</div>
</figure>
</Reveal>
);
};
-26
View File
@@ -1,26 +0,0 @@
"use client";
import React, { useEffect, useState } from "react";
/**
* Automatisierte Anzeige der Projekt-Verfügbarkeit nach Quartalen.
* Berechnet das aktuelle Quartal basierend auf dem heutigen Datum.
*/
export const Availability: React.FC = () => {
const [text, setText] = useState<string>("");
useEffect(() => {
const now = new Date();
const month = now.getMonth(); // 0-11
const year = now.getFullYear();
const quarter = Math.floor(month / 3) + 1;
// Aktuelles Quartal formatiert (z.B. Q2 2026)
setText(`Q${quarter} ${year}`);
}, []);
// Während Hydrierung nichts rendern, um Mismatches zu vermeiden (bei Client-only Logic)
if (!text) return <span className="opacity-0">Q- 202-</span>;
return <>{text}</>;
};
@@ -1,258 +0,0 @@
import React from "react";
interface BlogOGImageTemplateProps {
title: string;
description?: string;
label?: string;
accentColor?: string;
keyword?: string;
backgroundImageSrc?: string;
}
export function BlogOGImageTemplate({
title,
description,
label,
accentColor,
keyword,
backgroundImageSrc,
}: BlogOGImageTemplateProps) {
const accent = accentColor || "#3b82f6";
const white = "#ffffff";
const slateLight = "#cbd5e1"; // slate-300 for readable descriptions on dark bg
const containerStyle: React.CSSProperties = {
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "center",
backgroundColor: "#0f172a", // base dark slate
padding: "80px",
position: "relative",
fontFamily: "Inter",
};
return (
<div style={containerStyle}>
{/* Primary Thumbnail Background Image */}
{backgroundImageSrc && (
<img
src={backgroundImageSrc}
width="1200"
height="630"
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
{/* Dark overlay to make text highly legible */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
// Deep dark gradient that is almost solid on the left and fades out slightly on the right
backgroundImage: "linear-gradient(to right, rgba(15, 23, 42, 0.98) 0%, rgba(15, 23, 42, 0.88) 50%, rgba(15, 23, 42, 0.6) 100%)",
}}
/>
{/* Subtle Grid Pattern overlay */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: `linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)`,
backgroundSize: "40px 40px",
display: "flex",
}}
/>
{/* Accent geometric block (right side) */}
<div
style={{
position: "absolute",
top: "60px",
right: "80px",
width: "200px",
height: "200px",
borderRadius: "24px",
border: `3px solid ${accent}`,
opacity: 0.3,
display: "flex",
}}
/>
<div
style={{
position: "absolute",
top: "100px",
right: "120px",
width: "160px",
height: "160px",
borderRadius: "20px",
backgroundColor: accent,
opacity: 0.15,
display: "flex",
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
position: "relative",
zIndex: 10,
maxWidth: "850px",
}}
>
{/* Label / Category */}
{label && (
<div
style={{
fontSize: "18px",
fontWeight: 700,
color: accent,
textTransform: "uppercase",
letterSpacing: "0.2em",
marginBottom: "24px",
display: "flex",
}}
>
{label}
</div>
)}
{/* Title */}
<div
style={{
fontSize: title.length > 40 ? "56px" : "72px",
fontWeight: 700,
color: white,
lineHeight: "1.1",
marginBottom: "28px",
display: "flex",
letterSpacing: "-0.03em",
}}
>
{title}
</div>
{/* Description */}
{description && (
<div
style={{
fontSize: "26px",
color: slateLight,
lineHeight: "1.45",
display: "flex",
fontWeight: 400,
}}
>
{description.length > 120
? description.substring(0, 117) + "..."
: description}
</div>
)}
</div>
{/* Brand Footer */}
<div
style={{
position: "absolute",
bottom: "60px",
left: "80px",
display: "flex",
alignItems: "center",
}}
>
<div
style={{
width: "50px",
height: "3px",
backgroundColor: white,
borderRadius: "2px",
marginRight: "16px",
opacity: 0.8,
}}
/>
<div
style={{
fontSize: "20px",
fontWeight: 700,
color: white,
textTransform: "lowercase",
letterSpacing: "0.05em",
display: "flex",
opacity: 0.8,
}}
>
mintel.me
</div>
</div>
{/* Keyword badge (bottom-right) */}
{keyword && (
<div
style={{
position: "absolute",
bottom: "60px",
right: "80px",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div
style={{
width: "8px",
height: "8px",
borderRadius: "4px",
backgroundColor: accent,
display: "flex",
opacity: 0.8,
}}
/>
<div
style={{
fontSize: "14px",
fontWeight: 700,
color: white,
textTransform: "uppercase",
letterSpacing: "0.15em",
fontFamily: "ui-monospace, monospace",
display: "flex",
opacity: 0.6,
}}
>
{keyword}
</div>
</div>
)}
{/* Accent Strip */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "5px",
backgroundColor: accent,
}}
/>
</div>
);
}
@@ -1,33 +0,0 @@
"use client";
import * as React from "react";
import { useEffect } from "react";
interface BlogPostClientProps {
readingTime: number;
title: string;
}
export const BlogPostClient: React.FC<BlogPostClientProps> = () => {
useEffect(() => {
const handleScroll = () => {
// Update progress bar
const winScroll =
document.body.scrollTop || document.documentElement.scrollTop;
const height =
document.documentElement.scrollHeight -
document.documentElement.clientHeight;
const scrolled = winScroll / height;
const progressBar = document.querySelector(
".reading-progress-bar",
) as HTMLElement;
if (progressBar) {
progressBar.style.transform = `scaleX(${scrolled})`;
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return <div className="reading-progress-bar" />;
};
-120
View File
@@ -1,120 +0,0 @@
'use client';
import React, { useEffect, useRef, useState, useId } from 'react';
import { ComponentShareButton } from './ComponentShareButton';
interface BoldNumberProps {
/** The number to display, e.g. "53%" or "2.5M€" or "-20%" */
value: string;
/** Short description of what this number means */
label: string;
/** Source attribution */
source?: string;
/** Source URL */
sourceUrl?: string;
className?: string;
}
/**
* Premium hero number component — full-width, dark gradient, animated count-up.
* Designed for shareable key statistics that stand out in blog posts.
*/
export const BoldNumber: React.FC<BoldNumberProps> = ({
value,
label,
source,
sourceUrl,
className = '',
}) => {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [displayValue, setDisplayValue] = useState('');
const shareId = `boldnum-${useId().replace(/:/g, '')}`;
// Extract numeric part for animation
const numericMatch = value.match(/^([+-]?)(\d+(?:[.,]\d+)?)(.*)/);
const prefix = numericMatch?.[1] ?? '';
const numStr = numericMatch?.[2] ?? '';
const suffix = numericMatch?.[3] ?? value;
const targetNum = parseFloat(numStr.replace(',', '.')) || 0;
const hasDecimals = numStr.includes('.') || numStr.includes(',');
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ threshold: 0.3 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!isVisible || !numStr) {
setDisplayValue(value);
return;
}
const duration = 1200;
const steps = 40;
const stepTime = duration / steps;
let step = 0;
const timer = setInterval(() => {
step++;
const progress = Math.min(step / steps, 1);
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
const current = targetNum * eased;
const formatted = hasDecimals ? current.toFixed(1) : Math.round(current).toString();
setDisplayValue(`${prefix}${formatted}${suffix}`);
if (step >= steps) {
clearInterval(timer);
setDisplayValue(value);
}
}, stepTime);
return () => clearInterval(timer);
}, [isVisible, value, prefix, suffix, targetNum, hasDecimals, numStr]);
return (
<div
ref={ref}
id={shareId}
className={`not-prose relative overflow-hidden rounded-2xl my-16 border border-slate-100 bg-slate-50/50 p-10 md:p-14 text-center group ${className}`}
>
<div className="absolute top-4 right-4 z-50 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500">
<ComponentShareButton targetId={shareId} title={`Statistik: ${label}`} />
</div>
<div className="relative z-10">
<span className="block text-6xl md:text-8xl font-black tracking-tighter tabular-nums leading-none text-slate-900 pb-2">
{displayValue || value}
</span>
<span className="block mt-4 text-base md:text-lg font-medium text-slate-500 uppercase tracking-widest max-w-lg mx-auto">
{label}
</span>
{source && (
<span className="block mt-4 text-xs font-semibold text-slate-400">
{sourceUrl ? (
<a href={sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:text-blue-600 transition-colors">
Quelle: {source} ↗
</a>
) : (
`Quelle: ${source}`
)}
</span>
)}
</div>
</div>
);
};
-100
View File
@@ -1,100 +0,0 @@
'use client';
import React, { useRef, useState } from 'react';
interface CarouselItem {
title: string;
content: string;
icon?: React.ReactNode;
}
interface CarouselProps {
items: CarouselItem[];
className?: string;
}
export const Carousel: React.FC<CarouselProps> = ({ items, className = '' }) => {
const scrollRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
const scrollTo = (index: number) => {
if (!scrollRef.current) return;
const width = scrollRef.current.clientWidth;
scrollRef.current.scrollTo({ left: index * width, behavior: 'smooth' });
setActiveIndex(index);
};
const handleScroll = () => {
if (!scrollRef.current) return;
const width = scrollRef.current.clientWidth;
const index = Math.round(scrollRef.current.scrollLeft / width);
if (index !== activeIndex) setActiveIndex(index);
};
// Icons helper (default icon if none provided)
const DefaultIcon = () => (
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
);
return (
<div className={`not-prose my-16 px-12 md:px-16 ${className}`}>
<div className="relative group">
{/* Scroll Container */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex overflow-x-auto snap-x snap-mandatory scrollbar-hide rounded-2xl border border-slate-200 bg-slate-50 gap-4 p-4"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{(items || []).map((item, index) => (
<div
key={index}
className="min-w-[85%] md:min-w-[45%] snap-center p-8 md:p-10 flex flex-col gap-6 items-start bg-white rounded-xl border border-slate-100 shadow-sm"
>
<div className="flex-shrink-0 w-12 h-12 bg-slate-50 rounded-xl border border-slate-100 flex items-center justify-center">
{item.icon || <DefaultIcon />}
</div>
<div className="flex-1">
<h4 className="text-lg font-bold text-slate-900 mb-2">{item.title}</h4>
{item.content && (
<p className="text-sm text-slate-600 leading-relaxed m-0">{item.content}</p>
)}
</div>
</div>
))}
</div>
{/* Nav Buttons (Outside) */}
<button
onClick={() => scrollTo(Math.max(0, activeIndex - 1))}
disabled={activeIndex === 0}
className="absolute -left-12 md:-left-16 top-1/2 -translate-y-1/2 p-3 bg-white rounded-full shadow-sm border border-slate-200 text-slate-600 hover:text-slate-900 disabled:opacity-30 transition-all hidden md:block"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
</button>
<button
onClick={() => scrollTo(Math.min((items?.length || 0) - 1, activeIndex + 1))}
disabled={activeIndex === (items?.length || 0) - 1}
className="absolute -right-12 md:-right-16 top-1/2 -translate-y-1/2 p-3 bg-white rounded-full shadow-sm border border-slate-200 text-slate-600 hover:text-slate-900 disabled:opacity-30 transition-all hidden md:block"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
</button>
</div>
{/* Dots */}
<div className="flex justify-center mt-6 gap-3">
{(items || []).map((_, index) => (
<button
key={index}
onClick={() => scrollTo(index)}
className={`h-1.5 transition-all duration-300 rounded-full ${index === activeIndex ? 'w-8 bg-slate-900' : 'w-2 bg-slate-200 hover:bg-slate-300'
}`}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
</div>
);
};
@@ -1,80 +0,0 @@
"use client";
import * as React from "react";
import { Document as PDFDocument } from "@react-pdf/renderer";
import {
EstimationPDF,
AgbsPDF,
ClosingModule,
SimpleLayout,
} from "@mintel/pdf";
interface CombinedProps {
estimationProps: any;
showAgbs?: boolean;
techDetails?: any[];
principles?: any[];
maintenanceDetails?: any[];
standardsDetails?: any[];
}
export const CombinedQuotePDF = ({
estimationProps,
showAgbs = true,
techDetails,
principles,
maintenanceDetails,
standardsDetails,
mode = "full",
}: CombinedProps & { mode?: "estimation" | "full" }) => {
const date = new Date().toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
});
const companyData = {
name: "Marc Mintel",
address1: "Georg-Meistermann-Straße 7",
address2: "54586 Schüller",
ustId: "DE367588065",
};
const bankData = {
name: "N26",
bic: "NTSBDEB1XXX",
iban: "DE50 1001 1001 2620 4328 65",
};
const layoutProps = {
headerIcon: estimationProps.headerIcon,
footerLogo: estimationProps.footerLogo,
companyData,
bankData,
};
return (
<PDFDocument
title={`Mintel - ${estimationProps.state.companyName || estimationProps.state.name}`}
>
<EstimationPDF
{...estimationProps}
mode={mode}
techDetails={techDetails}
principles={principles}
maintenanceDetails={maintenanceDetails}
standardsDetails={standardsDetails}
/>
{showAgbs && (
<AgbsPDF
mode={mode}
headerIcon={estimationProps.headerIcon}
footerLogo={estimationProps.footerLogo}
/>
)}
<SimpleLayout {...layoutProps} showPageNumber={false}>
<ClosingModule />
</SimpleLayout>
</PDFDocument>
);
};
@@ -1,223 +0,0 @@
"use client";
import React, { useState } from "react";
import { Share2 } from "lucide-react";
import { ShareModal } from "./ShareModal";
import { useAnalytics } from "./analytics/useAnalytics";
import * as htmlToImage from "html-to-image";
import QRCode from "qrcode";
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
interface ComponentShareButtonProps {
targetId: string;
title?: string;
shareText?: string;
className?: string;
}
export const ComponentShareButton: React.FC<ComponentShareButtonProps> = ({
targetId,
title = "Mintel Architektur Insights",
shareText = "Schauen Sie sich diese spannende interaktive Simulation an:",
className = ""
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [generatedImage, setGeneratedImage] = useState<string | undefined>();
const [isCapturing, setIsCapturing] = useState(false);
const { trackEvent } = useAnalytics();
const currentUrl =
typeof window !== "undefined"
? `${window.location.origin}${window.location.pathname}#${targetId}`
: "";
const applyWatermark = async (base64Img: string, qrCodeSrc: string): Promise<string> => {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
// html-to-image uses pixelRatio: 2, so img.width/img.height are 2x
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (!ctx) return resolve(base64Img);
// Draw main image
ctx.drawImage(img, 0, 0);
// Watermark styling for 2x resolution
const padding = 32;
const wHeight = 110;
const wWidth = 360;
const x = canvas.width - wWidth - padding;
const y = canvas.height - wHeight - padding;
// Draw pill shape background
ctx.fillStyle = "rgba(255, 255, 255, 0.95)";
ctx.shadowColor = "rgba(0, 0, 0, 0.05)";
ctx.shadowBlur = 10;
ctx.shadowOffsetY = 4;
if (ctx.roundRect) {
ctx.beginPath();
ctx.roundRect(x, y, wWidth, wHeight, 24);
ctx.fill();
} else {
ctx.fillRect(x, y, wWidth, wHeight);
}
// Draw text and logo
ctx.shadowColor = "transparent";
ctx.textBaseline = "middle";
(async () => {
try {
const qrImg = new Image();
qrImg.src = qrCodeSrc;
await new Promise(r => { qrImg.onload = r; qrImg.onerror = r; });
const logoSrc = typeof LogoBlack === 'string' ? LogoBlack : (LogoBlack as any).src;
const logoImg = new Image();
logoImg.src = logoSrc;
await new Promise(r => { logoImg.onload = r; logoImg.onerror = r; });
if (logoImg.width > 0) {
// Calculate logo dimensions
const logoHeight = 32;
const logoWidth = logoImg.width * (logoHeight / logoImg.height);
const logoX = x + 24;
const logoY = y + (wHeight - logoHeight) / 2;
// Draw Logo
ctx.drawImage(logoImg, logoX, logoY, logoWidth, logoHeight);
// Draw "Zum Artikel" text right of the logo
ctx.font = "600 20px sans-serif";
ctx.fillStyle = "#64748b";
ctx.textAlign = "left";
ctx.fillText("Zum Artikel", logoX + logoWidth + 24, y + wHeight / 2 + 2);
} else {
// Fallback if logo fails to load for some reason
ctx.font = "900 36px sans-serif";
ctx.fillStyle = "#0f172a";
ctx.textAlign = "left";
ctx.fillText("mintel.", x + 24, y + wHeight / 2 + 4);
}
// Draw solid line separator before QR
const qrSize = wHeight - 24; // padding 12*2 inside pill
const qrX = x + wWidth - qrSize - 12;
const qrY = y + 12;
const sepX = qrX - 12;
ctx.beginPath();
ctx.moveTo(sepX, y + 20);
ctx.lineTo(sepX, y + wHeight - 20);
ctx.strokeStyle = "#e2e8f0";
ctx.lineWidth = 2;
ctx.stroke();
// Draw QR Code
if (qrImg.width > 0) {
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
}
resolve(canvas.toDataURL("image/png"));
} catch (e) {
resolve(base64Img); // Error fallback
}
})();
};
img.onerror = () => resolve(base64Img); // Error fallback
img.src = base64Img;
});
};
const handleOpenModal = async () => {
setIsCapturing(true);
try {
const element = document.getElementById(targetId);
if (element) {
// Force explicit dimensions to prevent html-to-image from clipping
const w = element.offsetWidth;
const h = element.offsetHeight;
const rawDataUrl = await htmlToImage.toPng(element, {
quality: 1,
type: 'image/png',
pixelRatio: 2,
backgroundColor: 'white',
skipFonts: true,
width: w,
height: h,
style: {
transform: 'none',
margin: '0',
width: `${w}px`,
height: `${h}px`
},
filter: (node) => {
const el = node as HTMLElement;
if (el.tagName === 'BUTTON' || el.dataset?.shareButton === 'true' || el.dataset?.shareWrapper === 'true') {
return false;
}
return true;
}
});
// Generate QR Code
const qrDataUrl = await QRCode.toDataURL(currentUrl, {
width: 120, // slightly larger for sharper scaling
margin: 0,
color: { dark: '#0f172a', light: '#ffffff' }
});
// Add Watermark
const watermarkedImage = await applyWatermark(rawDataUrl, qrDataUrl);
setGeneratedImage(watermarkedImage);
}
} catch (err) {
console.error("Failed to capture component:", err);
} finally {
setIsCapturing(false);
setIsModalOpen(true);
trackEvent("component_share_opened", {
component_id: targetId,
component_title: title
});
}
};
return (
<div className={`relative ${className}`}>
<button
onClick={handleOpenModal}
disabled={isCapturing}
data-share-button="true"
className={`flex items-center gap-2 px-3 py-1.5 rounded-full font-medium text-xs transition-all shadow-sm
${isCapturing
? "bg-slate-100 text-slate-400 cursor-wait border border-slate-200"
: "bg-white text-slate-600 hover:text-slate-900 border border-slate-200 hover:border-slate-300 hover:shadow-md"
}`}
title="Diesen Abschnitt als Bild teilen"
>
{isCapturing ? (
<div className="w-3.5 h-3.5 border-2 border-slate-300 border-t-slate-500 rounded-full animate-spin" />
) : (
<Share2 size={14} />
)}
<span>Teilen</span>
</button>
<ShareModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
url={currentUrl}
title={title}
description={shareText}
diagramImage={generatedImage}
qrCodeData={generatedImage} // Let's try sending it to both to be safe depending on ShareModal's internals
/>
</div>
);
};
-421
View File
@@ -1,421 +0,0 @@
"use client";
import * as React from "react";
import { useState, useMemo, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import confetti from "canvas-confetti";
import {
Layers,
BrainCircuit,
Workflow,
Plug,
CheckCircle2,
AlertCircle,
} from "lucide-react";
import { useAnalytics } from "./analytics/useAnalytics";
import { AnalyticsEvents } from "./analytics/analytics-events";
import { FormState } from "./ContactForm/types";
import {
PRICING,
initialState,
FEATURE_OPTIONS,
FUNCTION_OPTIONS,
API_OPTIONS,
PAGE_SAMPLES,
ASSET_OPTIONS,
} from "./ContactForm/constants";
import { calculateTotals } from "@mintel/pdf";
import { sendContactInquiry } from "../actions/contact";
// Configurator Components
import { ConfiguratorLayout } from "./ContactForm/Configurator/ConfiguratorLayout";
import { NarrativeInput } from "./ContactForm/Configurator/NarrativeInput";
import { ModuleGrid } from "./ContactForm/Configurator/ModuleGrid";
import { ReferenceInput } from "./ContactForm/Configurator/ReferenceInput";
import { Launchpad } from "./ContactForm/Configurator/Launchpad";
import { Reveal } from "./Reveal";
interface ContactFormProps {
initialStepIndex?: number;
initialState?: Partial<FormState>;
}
const CONFIGURATOR_STEPS = [
{ id: "intro", title: "INITIALISIERUNG" },
{ id: "scope", title: "PROJEKT_UMFANG" },
{ id: "refs", title: "INSPIRATIONS_QUELLE" },
{ id: "assets", title: "BESTANDS_AUFNAHME" },
{ id: "features", title: "KERN_MODULE" },
{ id: "functions", title: "FUNKTIONALE_ERW" },
{ id: "api", title: "SYSTEM_INTEGRATION" },
{ id: "launch", title: "FINALE_SEQUENZ" },
];
import { ContactGateway } from "./ContactForm/ContactGateway";
import { DirectMessageFlow } from "./ContactForm/DirectMessageFlow";
type FlowState = "discovery" | "configurator" | "direct-message";
export function ContactForm({
initialStepIndex = 0,
initialState: injectedState,
}: ContactFormProps) {
const [flow, setFlow] = useState<FlowState>("direct-message");
const [stepIndex, setStepIndex] = useState(initialStepIndex);
const [state, setState] = useState<FormState>({
...initialState,
...injectedState,
});
const [isSubmitted, setIsSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { trackEvent } = useAnalytics();
const containerRef = useRef<HTMLDivElement>(null);
// Scroll to top on flow or step change
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTo({ top: 0, behavior: "smooth" });
} else {
window.scrollTo({ top: 0, behavior: "smooth" });
}
}, [flow, stepIndex]);
// Keyboard Navigation (only for configurator)
useEffect(() => {
if (flow !== "configurator") return;
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
)
return;
if (e.key === "Enter" && stepIndex < CONFIGURATOR_STEPS.length - 1) {
handleNext();
} else if (e.key === "Backspace" && stepIndex > 0) {
handlePrev();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [stepIndex, flow]);
const totals = useMemo(() => calculateTotals(state as any, PRICING), [state]);
const updateState = (updates: Partial<FormState>) => {
setState((prev) => ({ ...prev, ...updates }));
};
const toggleItem = (list: string[], id: string) => {
return list.includes(id) ? list.filter((i) => i !== id) : [...list, id];
};
const handleNext = () => setStepIndex((prev) => prev + 1);
const handlePrev = () => setStepIndex((prev) => Math.max(0, prev - 1));
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
setIsSubmitting(true);
setError(null);
const result = await sendContactInquiry({
name: state.name,
email: state.email,
phone: state.phone,
role: state.role,
companyName: state.companyName,
projectType: state.projectType,
deadline: state.deadline,
message: state.message,
isFreeText: flow === "direct-message",
config: flow === "configurator" ? state : undefined,
});
setIsSubmitting(false);
if (result.success) {
setIsSubmitted(true);
trackEvent(AnalyticsEvents.CONTACT_FORM_SUBMIT, {
flow,
config: flow === "configurator" ? state : undefined,
});
// Celebration
const duration = 3 * 1000;
const animationEnd = Date.now() + duration;
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
const randomInRange = (min: number, max: number) =>
Math.random() * (max - min) + min;
const interval: any = setInterval(function () {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) return clearInterval(interval);
const particleCount = 50 * (timeLeft / duration);
(confetti as any)({
...defaults,
particleCount,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
});
(confetti as any)({
...defaults,
particleCount,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
});
}, 250);
} else {
setError(result.error || "Unerwarteter Fehler bei der Übertragung.");
}
};
// Success view (unified)
if (isSubmitted) {
return (
<div className="w-full min-h-[50vh] bg-white flex flex-col items-center justify-center p-8 text-center space-y-8 z-50 rounded-xl border border-slate-100 shadow-sm">
<Reveal width="100%" delay={0.1}>
<div className="flex flex-col items-center space-y-8">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring" }}
className="w-24 h-24 bg-green-500 rounded-full flex items-center justify-center text-slate-900 mb-4"
>
<CheckCircle2 size={48} />
</motion.div>
<h1 className="text-3xl md:text-5xl font-mono text-slate-900 tracking-widest">
SEQUENZ_INITIIERT
</h1>
<p className="text-slate-600 font-mono">
Das System wird Sie in Kürze unter {state.email} kontaktieren.
</p>
<button
onClick={() => {
setIsSubmitted(false);
setFlow("direct-message"); // Reset back to dm
setStepIndex(0);
setState(initialState);
}}
className="mt-8 text-green-600 underline font-mono hover:text-green-800"
>
[ NEUE_SEQUENZ_STARTEN ]
</button>
</div>
</Reveal>
</div>
);
}
// Gateway Flow (Disabled but kept logically if needed, can just not render)
if (flow === "discovery") {
return (
<ContactGateway
name={state.name}
setName={(v) => updateState({ name: v })}
company={state.companyName}
setCompany={(v) => updateState({ companyName: v })}
projectType={state.projectType}
setProjectType={(v) => updateState({ projectType: v })}
onChooseConfigurator={() => setFlow("configurator")}
onChooseDirectMessage={() => setFlow("direct-message")}
/>
);
}
// Direct Message Flow
if (flow === "direct-message") {
return (
<DirectMessageFlow
name={state.name}
setName={(v) => updateState({ name: v })}
email={state.email}
setEmail={(v) => updateState({ email: v })}
phone={state.phone}
setPhone={(v) => updateState({ phone: v })}
role={state.role}
setRole={(v) => updateState({ role: v })}
company={state.companyName}
setCompany={(v) => updateState({ companyName: v })}
projectType={state.projectType}
setProjectType={(v) => updateState({ projectType: v })}
deadline={state.deadline}
setDeadline={(v) => updateState({ deadline: v })}
message={state.message}
setMessage={(v) => updateState({ message: v })}
onBack={() => setFlow("discovery")} // Can keep, but won't be seen if we hide button
onSubmit={() => handleSubmit()}
isSubmitting={isSubmitting}
error={error}
/>
);
}
// Configurator Flow
const renderConfiguratorContent = () => {
switch (CONFIGURATOR_STEPS[stepIndex].id) {
case "intro":
return (
<NarrativeInput
name={state.name}
setName={(v) => updateState({ name: v })}
company={state.companyName}
setCompany={(v) => updateState({ companyName: v })}
projectType={state.projectType}
setProjectType={(v) => updateState({ projectType: v })}
/>
);
case "scope":
return (
<ModuleGrid
title="SYSTEM MODULE"
options={PAGE_SAMPLES.map((p) => ({
id: p.id,
title: p.label,
description: p.desc,
icon: <Layers size={20} />,
}))}
selected={state.selectedPages}
onToggle={(id) =>
updateState({
selectedPages: toggleItem(state.selectedPages, id),
})
}
otherCount={state.otherPagesCount}
onOtherCountChange={(val) => updateState({ otherPagesCount: val })}
otherLabel="Weitere Seiten"
/>
);
case "refs":
return (
<ReferenceInput
references={state.references}
setReferences={(refs) => updateState({ references: refs })}
/>
);
case "assets":
return (
<ModuleGrid
title="VORHANDENE ASSETS"
options={ASSET_OPTIONS.map((a) => ({
id: a.id,
title: a.label,
description: a.desc,
icon: <Layers size={20} />,
}))}
selected={state.assets}
onToggle={(id) =>
updateState({ assets: toggleItem(state.assets, id) })
}
/>
);
case "features":
return (
<ModuleGrid
title="KERN-FEATURES"
options={FEATURE_OPTIONS.map((f) => ({
id: f.id,
title: f.label,
description: f.desc,
priceEstimate: "€€",
icon: <BrainCircuit size={20} />,
}))}
selected={state.features}
onToggle={(id) =>
updateState({ features: toggleItem(state.features, id) })
}
otherCount={state.otherFeaturesCount}
onOtherCountChange={(val) =>
updateState({ otherFeaturesCount: val })
}
otherLabel="Zusätzliche Features"
/>
);
case "functions":
return (
<ModuleGrid
title="ERWEITERTE FUNKTIONEN"
options={FUNCTION_OPTIONS.map((f) => ({
id: f.id,
title: f.label,
description: f.desc,
priceEstimate: "€€€",
icon: <Workflow size={20} />,
}))}
selected={state.functions}
onToggle={(id) =>
updateState({ functions: toggleItem(state.functions, id) })
}
/>
);
case "api":
return (
<ModuleGrid
title="SYSTEM-INTEGRATIONEN"
options={API_OPTIONS.map((a) => ({
id: a.id,
title: a.label,
description: a.desc,
priceEstimate: "€€€",
icon: <Plug size={20} />,
}))}
selected={state.apiSystems}
onToggle={(id) =>
updateState({ apiSystems: toggleItem(state.apiSystems, id) })
}
/>
);
case "launch":
return (
<div className="space-y-6">
{error && (
<Reveal width="100%" direction="none">
<div className="flex items-center gap-3 p-4 bg-red-50 text-red-700 rounded-xl border border-red-100 mb-6">
<AlertCircle size={20} />
<span className="text-sm font-bold uppercase tracking-wider">
{error}
</span>
</div>
</Reveal>
)}
<Launchpad
email={state.email}
setEmail={(v) => updateState({ email: v })}
timeline={state.deadline}
setTimeline={(v) => updateState({ deadline: v })}
message={state.message}
setMessage={(v) => updateState({ message: v })}
onSubmit={handleSubmit}
isValid={!!state.email && !!state.name}
/>
</div>
);
default:
return null;
}
};
return (
<div ref={containerRef} className="w-full h-full overflow-y-auto">
<ConfiguratorLayout
stepIndex={stepIndex}
totalSteps={CONFIGURATOR_STEPS.length}
title={CONFIGURATOR_STEPS[stepIndex].title}
onNext={
stepIndex < CONFIGURATOR_STEPS.length - 1 ? handleNext : undefined
}
onPrev={stepIndex > 0 ? handlePrev : undefined}
isSubmitting={isSubmitting}
totalPrice={totals.totalPrice}
monthlyPrice={totals.monthlyPrice}
onRestart={() => {
setFlow("discovery");
setStepIndex(0);
setState(initialState);
}}
>
{renderConfiguratorContent()}
</ConfiguratorLayout>
</div>
);
}
@@ -1,162 +0,0 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { AbstractCircuit } from "../../Effects/AbstractCircuit";
import { RotateCcw, ChevronRight, ChevronLeft } from "lucide-react";
interface ConfiguratorLayoutProps {
children: React.ReactNode;
stepIndex: number;
totalSteps: number;
title: string;
onNext?: () => void;
onPrev?: () => void;
isSubmitting?: boolean;
totalPrice?: number;
monthlyPrice?: number;
onRestart?: () => void;
}
export const ConfiguratorLayout = ({
children,
stepIndex,
totalSteps,
title,
onNext,
onPrev,
isSubmitting,
totalPrice = 0,
monthlyPrice = 0,
onRestart,
}: ConfiguratorLayoutProps) => {
const handleRestart = () => {
if (
window.confirm("Konfiguration neustarten? Ihr Fortschritt geht verloren.")
) {
if (onRestart) {
onRestart();
} else {
window.location.reload();
}
}
};
return (
<div className="relative w-full min-h-[85vh] bg-white text-slate-900 flex flex-col overflow-hidden border border-slate-200 rounded-xl shadow-lg">
{/* Background: Geometric Dot Grid */}
<div
className="absolute inset-0 z-0 opacity-[0.4] pointer-events-none"
style={{
backgroundImage: `radial-gradient(#cbd5e1 1px, transparent 1px)`,
backgroundSize: `24px 24px`,
}}
/>
{/* Subtle Circuit Overlay */}
<div className="absolute inset-0 z-0 opacity-[0.05] pointer-events-none mix-blend-multiply">
<AbstractCircuit />
</div>
{/* Header (Functional) */}
<header className="relative z-10 flex flex-col border-b border-slate-200 bg-white/80 backdrop-blur-sm">
{/* Top Bar: Controls */}
<div className="flex items-center justify-between px-6 py-4 md:px-8">
<div className="flex items-center gap-3">
<div className="relative flex items-center justify-center w-4 h-4">
<div className="w-2 h-2 rounded-full bg-green-500 shadow-sm" />
</div>
<div className="flex flex-col">
<span className="text-sm font-bold text-slate-900 leading-none tracking-tight">
SYSTEM KONFIGURATOR
</span>
<span className="text-[10px] font-mono text-slate-400 uppercase tracking-widest mt-0.5">
Schritt {String(stepIndex + 1).padStart(2, "0")} /{" "}
{String(totalSteps).padStart(2, "0")}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRestart}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold text-slate-400 hover:text-red-600 hover:bg-slate-50 transition-colors uppercase tracking-wider"
>
<RotateCcw size={14} />{" "}
<span className="hidden md:inline">Zurücksetzen</span>
</button>
</div>
</div>
{/* Progress Line */}
<div className="relative w-full h-1 bg-slate-100">
<motion.div
className="absolute top-0 left-0 h-full bg-slate-900"
initial={{ width: 0 }}
animate={{ width: `${((stepIndex + 1) / totalSteps) * 100}%` }}
transition={{ duration: 0.5, ease: "easeInOut" }}
/>
</div>
</header>
{/* Main Content Area */}
<main className="flex-1 relative z-10 flex flex-col items-center justify-center p-6 md:p-12 w-full overflow-y-auto">
<AnimatePresence mode="wait">
<motion.div
key={stepIndex}
initial={{ opacity: 0, y: 15, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -15, filter: "blur(4px)" }}
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
className="w-full max-w-5xl"
>
{children}
</motion.div>
</AnimatePresence>
</main>
{/* Footer / Controls */}
<footer className="relative z-10 flex flex-col md:flex-row items-center justify-between px-6 py-5 md:px-8 border-t border-slate-200 bg-white/90 backdrop-blur-sm gap-4">
{/* Left: Live Estimate (Integrated, No Overlay) */}
<div className="w-full md:w-auto flex items-center gap-6">
<div className="flex flex-col">
<span className="text-[10px] font-bold font-mono text-slate-400 uppercase tracking-widest mb-0.5">
Kalkuliertes Budget
</span>
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold text-slate-900">
€{totalPrice.toLocaleString("de-DE")}
</span>
{monthlyPrice > 0 && (
<span className="text-xs font-medium text-slate-500">
+ €{monthlyPrice}/mtl.
</span>
)}
</div>
</div>
</div>
{/* Right: Navigation */}
<div className="flex items-center gap-3 w-full md:w-auto justify-end">
{onPrev && (
<button
onClick={onPrev}
className="px-5 py-3 rounded-xl text-xs font-bold font-mono tracking-wider text-slate-500 hover:text-slate-900 hover:bg-slate-50 transition-all flex items-center gap-2"
>
<ChevronLeft size={16} /> ZURÜCK
</button>
)}
{onNext && (
<button
onClick={onNext}
disabled={isSubmitting}
className="px-8 py-3 bg-slate-900 text-white rounded-xl text-xs font-bold font-mono tracking-wider hover:bg-slate-800 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transform active:scale-[0.98]"
>
{isSubmitting ? "VERARBEITE..." : "WEITER"}
{!isSubmitting && <ChevronRight size={16} />}
</button>
)}
</div>
</footer>
</div>
);
};
@@ -1,145 +0,0 @@
"use client";
import { motion } from "framer-motion";
import { Mail, Calendar, MessageSquare, Rocket } from "lucide-react";
import { cn } from "../../../utils/cn";
import { Reveal } from "../../Reveal";
interface LaunchpadProps {
email: string;
setEmail: (val: string) => void;
timeline: string;
setTimeline: (val: string) => void;
message: string;
setMessage: (val: string) => void;
onSubmit: () => void;
isValid: boolean;
}
export const Launchpad = ({
email,
setEmail,
timeline,
setTimeline,
message,
setMessage,
onSubmit,
isValid,
}: LaunchpadProps) => {
return (
<div className="w-full max-w-3xl mx-auto space-y-12 pb-12">
<Reveal width="100%" delay={0.1}>
<div className="text-center space-y-4">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-slate-100 text-[10px] font-bold uppercase tracking-widest text-slate-500 mb-2">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
System bereit
</div>
<h2 className="text-4xl md:text-5xl font-bold text-slate-900 tracking-tight">
Launch-Sequenz initialisieren
</h2>
<p className="text-slate-500 text-lg max-w-xl mx-auto leading-relaxed">
Bitte bestätigen Sie die finalen Parameter, um den Prozess zu
starten.
</p>
</div>
</Reveal>
<Reveal width="100%" delay={0.2}>
<div className="space-y-8 bg-white p-8 md:p-10 rounded-3xl border border-slate-100 shadow-xl shadow-slate-200/50">
{/* Email Input */}
<div className="space-y-3">
<label className="text-xs font-bold font-mono text-slate-900 uppercase tracking-wider flex items-center gap-2">
<Mail size={14} className="text-slate-400" /> Kommunikations-Kanal
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@firma.de"
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-5 text-slate-900 text-lg font-medium focus:border-green-500 focus:ring-4 focus:ring-green-500/10 focus:outline-none transition-all placeholder:text-slate-400"
/>
</div>
{/* Timeline Selection */}
<div className="space-y-3">
<label className="text-xs font-bold font-mono text-slate-900 uppercase tracking-wider flex items-center gap-2">
<Calendar size={14} className="text-slate-400" /> Zeitfenster
</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ id: "asap", label: "ASAP", sub: "Sofort" },
{ id: "1month", label: "< 1 Monat", sub: "Priorität" },
{ id: "3months", label: "1-3 Monate", sub: "Standard" },
{ id: "flexible", label: "Flexibel", sub: "Kein Stress" },
].map((t) => (
<button
key={t.id}
onClick={() => setTimeline(t.id)}
className={cn(
"flex flex-col items-center justify-center p-4 rounded-xl border text-sm transition-all duration-200",
timeline === t.id
? "bg-slate-900 text-white border-slate-900 shadow-lg scale-[1.02]"
: "bg-white border-slate-200 text-slate-500 hover:border-slate-300 hover:bg-slate-50",
)}
>
<span className="font-bold">{t.label}</span>
<span
className={cn(
"text-[10px] uppercase tracking-wide mt-1",
timeline === t.id ? "text-slate-400" : "text-slate-400",
)}
>
{t.sub}
</span>
</button>
))}
</div>
</div>
{/* Message Input */}
<div className="space-y-3">
<label className="text-xs font-bold font-mono text-slate-900 uppercase tracking-wider flex items-center gap-2">
<MessageSquare size={14} className="text-slate-400" />{" "}
Transmission Payload (Optional)
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Spezielle Anforderungen, Briefing-Links oder Fragen..."
className="w-full bg-slate-50 border border-slate-200 rounded-xl p-5 text-slate-900 font-medium min-h-[140px] focus:border-slate-400 focus:outline-none transition-all placeholder:text-slate-400 resize-none"
/>
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.3}>
<div className="flex flex-col items-center gap-6">
<motion.button
whileHover={{
scale: 1.02,
boxShadow: "0 20px 40px -10px rgba(0,0,0,0.2)",
}}
whileTap={{ scale: 0.98 }}
onClick={onSubmit}
disabled={!isValid}
className="relative group w-full md:w-auto min-w-[300px] px-8 py-5 bg-slate-900 text-white text-lg font-bold font-mono tracking-widest rounded-xl overflow-hidden disabled:opacity-50 disabled:cursor-not-allowed shadow-xl transition-all"
>
<span className="relative z-10 flex items-center justify-center gap-3">
ANFRAGE SENDEN{" "}
<Rocket
size={20}
className="group-hover:translate-x-1 transition-transform"
/>
</span>
<div className="absolute inset-0 bg-gradient-to-r from-green-600 to-green-500 transform translate-y-full group-hover:translate-y-0 transition-transform duration-300 z-0" />
</motion.button>
<p className="text-xs text-slate-400 font-medium flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
Sichere 256-Bit verschlüsselte Übertragung
</p>
</div>
</Reveal>
</div>
);
};
@@ -1,168 +0,0 @@
"use client";
import { Check, Plus } from "lucide-react";
import { cn } from "../../../utils/cn";
import { Reveal } from "../../Reveal";
interface ModuleOption {
id: string;
title: string;
description: string;
priceEstimate?: string;
icon?: React.ReactNode;
}
interface ModuleGridProps {
title: string;
options: ModuleOption[];
selected: string[];
onToggle: (id: string) => void;
maxSelection?: number;
otherCount?: number;
onOtherCountChange?: (val: number) => void;
otherLabel?: string;
}
export const ModuleGrid = ({
title,
options,
selected,
onToggle,
otherCount = 0,
onOtherCountChange,
otherLabel = "Zusätzliche Elemente",
}: ModuleGridProps) => {
return (
<div className="w-full">
<Reveal width="100%" delay={0.05} direction="none">
<div className="flex items-center gap-4 mb-8">
<div className="h-px bg-slate-200 flex-1" />
<h3 className="text-xl font-bold text-slate-900 uppercase tracking-widest text-center min-w-max px-4">
{title}
</h3>
<div className="h-px bg-slate-200 flex-1" />
</div>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{options.map((option, i) => {
const isSelected = selected.includes(option.id);
return (
<Reveal
key={option.id}
width="100%"
delay={i * 0.05}
direction="up"
scale={0.95}
>
<button
onClick={() => onToggle(option.id)}
className={cn(
"relative flex flex-col items-start p-6 rounded-2xl border text-left w-full h-full transition-all duration-300",
isSelected
? "bg-green-50/40 border-green-500 ring-4 ring-green-500/10 shadow-xl scale-[1.02] z-10"
: "bg-white border-slate-200 hover:border-slate-300 hover:shadow-lg hover:-translate-y-1 hover:z-10",
)}
>
<div className="flex items-start justify-between w-full mb-4">
<div
className={cn(
"w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300 shadow-sm",
isSelected
? "bg-green-500 text-white shadow-green-500/30 ring-4 ring-green-100"
: "bg-slate-50 text-slate-500 group-hover:bg-slate-100",
)}
>
{option.icon ||
(isSelected ? <Check size={24} /> : <Plus size={24} />)}
</div>
{option.priceEstimate && (
<span
className={cn(
"px-2.5 py-1 rounded text-[10px] font-bold font-mono uppercase tracking-wider transition-colors",
isSelected
? "bg-green-100 text-green-700"
: "bg-slate-100 text-slate-500",
)}
>
+{option.priceEstimate}
</span>
)}
</div>
<div className="flex-1">
<h4
className={cn(
"text-lg font-bold mb-2 transition-colors",
isSelected ? "text-green-900" : "text-slate-900",
)}
>
{option.title}
</h4>
<p
className={cn(
"text-sm leading-relaxed transition-colors",
isSelected ? "text-green-800/70" : "text-slate-500",
)}
>
{option.description}
</p>
</div>
{/* Selection Check Circle */}
<div
className={cn(
"absolute top-4 right-4 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all",
isSelected
? "border-green-500 bg-green-500 text-white scale-100 opacity-100"
: "border-slate-200 bg-transparent scale-90 opacity-0",
)}
>
<Check size={14} strokeWidth={3} />
</div>
</button>
</Reveal>
);
})}
{onOtherCountChange && (
<div className="col-span-1 md:col-span-2 lg:col-span-3 mt-4">
<Reveal width="100%" delay={options.length * 0.05} direction="up">
<div className="flex flex-col md:flex-row items-center justify-between p-6 bg-slate-50 border border-dashed border-slate-300 rounded-2xl gap-4">
<div className="flex flex-col">
<span className="text-slate-900 font-bold text-lg">
{otherLabel}
</span>
<span className="text-slate-500 text-sm">
Gibt es weitere Anforderungen, die oben nicht aufgeführt
sind?
</span>
</div>
<div className="flex items-center gap-4 bg-white p-2 rounded-xl border border-slate-200 shadow-sm">
<button
onClick={() =>
onOtherCountChange(Math.max(0, otherCount - 1))
}
className="w-10 h-10 flex items-center justify-center rounded-lg border border-slate-100 hover:bg-slate-50 transition-colors text-slate-400 hover:text-slate-900 font-bold text-xl"
>
-
</button>
<div className="min-w-[40px] text-center font-mono font-bold text-2xl text-slate-900">
{otherCount}
</div>
<button
onClick={() => onOtherCountChange(otherCount + 1)}
className="w-10 h-10 flex items-center justify-center rounded-lg border border-slate-100 hover:bg-slate-50 transition-colors text-slate-400 hover:text-slate-900 font-bold text-xl"
>
+
</button>
</div>
</div>
</Reveal>
</div>
)}
</div>
</div>
);
};
@@ -1,178 +0,0 @@
"use client";
import { motion } from "framer-motion";
import { cn } from "../../../utils/cn";
import { ProjectType } from "../types";
import { Reveal } from "../../Reveal";
import { MessageSquareText, ArrowRight } from "lucide-react";
interface NarrativeInputProps {
name: string;
setName: (val: string) => void;
company: string;
setCompany: (val: string) => void;
projectType: ProjectType;
setProjectType: (val: ProjectType) => void;
onToggleFreeText?: () => void;
}
const AutoInput = ({
value,
onChange,
placeholder,
autoFocus,
}: {
value: string;
onChange: (val: string) => void;
placeholder: string;
autoFocus?: boolean;
}) => {
return (
<div className="inline-grid items-center relative group mx-1">
{/* Invisible span to dictate width */}
<span className="col-start-1 row-start-1 opacity-0 pointer-events-none whitespace-pre border-b-2 border-transparent px-2 py-1 text-2xl md:text-5xl font-bold tracking-tight min-w-[140px]">
{value || placeholder}
</span>
{/* Actual Input */}
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
autoFocus={autoFocus}
className={cn(
"col-start-1 row-start-1 w-full h-full bg-transparent focus:outline-none px-2 py-1",
"text-2xl md:text-5xl font-bold tracking-tight transition-all duration-300",
"border-b-2",
value
? "text-slate-900 border-slate-900"
: "text-slate-300 border-slate-100 placeholder:text-slate-200 focus:border-green-500 focus:placeholder:text-slate-300",
)}
/>
</div>
);
};
export const NarrativeInput = ({
name,
setName,
company,
setCompany,
projectType,
setProjectType,
onToggleFreeText,
}: NarrativeInputProps) => {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-left w-full px-4 max-w-5xl mx-auto">
<div className="w-full space-y-20">
{/* Header Section */}
<Reveal width="100%" delay={0.1}>
<div className="space-y-4">
<span className="text-[10px] font-mono text-green-600 uppercase tracking-[0.3em] font-bold">
INITIALISIERUNG // SCHRITT_01
</span>
<div className="flex flex-wrap items-baseline gap-y-6 text-slate-900">
<span className="text-2xl md:text-5xl font-medium text-slate-400">
Hi, ich bin
</span>
<AutoInput
value={name}
onChange={setName}
placeholder="Ihr Name"
autoFocus
/>
<span className="text-2xl md:text-5xl font-medium text-slate-400">
von
</span>
<AutoInput
value={company}
onChange={setCompany}
placeholder="Firma / Projekt"
/>
</div>
</div>
</Reveal>
{/* Project Focus Section */}
<Reveal width="100%" delay={0.3} direction="up">
<div className="space-y-8 p-10 bg-slate-50/50 rounded-3xl border border-slate-100">
<div className="flex items-center gap-4">
<span className="text-[10px] font-mono text-slate-400 uppercase tracking-widest font-bold">
MISSION_OBJECTIVE
</span>
<div className="h-px bg-slate-200 flex-1" />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{(["website", "web-app", "ecommerce"] as ProjectType[]).map(
(type) => {
const isActive = projectType === type;
const labels = {
website: "Corpo Website",
"web-app": "Web Application",
ecommerce: "E-Commerce",
};
return (
<button
key={type}
onClick={() => setProjectType(type)}
className={cn(
"relative flex flex-col items-start p-6 rounded-2xl border transition-all duration-300",
isActive
? "bg-white border-slate-900 shadow-xl shadow-slate-200 -translate-y-1"
: "bg-transparent border-slate-200 text-slate-500 hover:border-slate-300 hover:bg-white",
)}
>
<span
className={cn(
"text-lg font-bold transition-colors",
isActive ? "text-slate-900" : "text-slate-400",
)}
>
{labels[type]}
</span>
<span className="text-xs font-mono mt-2 opacity-50 uppercase tracking-wider">
{isActive ? "[ AKTIVIERT ]" : "Auswählen"}
</span>
{isActive && (
<motion.div
layoutId="active-indicator"
className="absolute top-4 right-4 w-2 h-2 rounded-full bg-green-500 shadow-[0_0_10px_rgba(34,197,94,0.5)]"
/>
)}
</button>
);
},
)}
</div>
</div>
</Reveal>
{/* Footer / Alternative Action */}
<Reveal width="100%" delay={0.5} direction="up">
<div className="flex flex-col md:flex-row items-center justify-between gap-8 pt-8 border-t border-slate-100">
<p className="text-sm text-slate-400 font-medium max-w-sm">
Nutzen Sie unseren Konfigurator für eine präzise Aufwandsschätzung
oder senden Sie uns direkt eine Nachricht.
</p>
<button
onClick={onToggleFreeText}
className="group flex items-center gap-3 px-6 py-3 rounded-full bg-white border border-slate-200 text-slate-600 text-sm font-bold hover:border-slate-900 hover:text-slate-900 transition-all duration-300"
>
<MessageSquareText
size={18}
className="text-slate-400 group-hover:text-slate-900"
/>
<span>Direktnachricht senden</span>
<ArrowRight
size={16}
className="opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all"
/>
</button>
</div>
</Reveal>
</div>
</div>
);
};
@@ -1,106 +0,0 @@
"use client";
import * as React from "react";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Plus, X, Link2, Globe } from "lucide-react";
import { cn } from "../../../utils/cn";
interface ReferenceInputProps {
references: string[];
setReferences: (refs: string[]) => void;
}
export const ReferenceInput = ({
references,
setReferences,
}: ReferenceInputProps) => {
const [inputValue, setInputValue] = useState("");
const addReference = () => {
if (inputValue.trim() && !references.includes(inputValue.trim())) {
setReferences([...references, inputValue.trim()]);
setInputValue("");
}
};
const removeReference = (ref: string) => {
setReferences(references.filter((r) => r !== ref));
};
return (
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-4 mb-8">
<div className="h-px bg-slate-200 flex-1" />
<h3 className="text-xl font-bold text-slate-900 uppercase tracking-widest text-center min-w-max px-4">
REFERENZEN & INSPIRATION
</h3>
<div className="h-px bg-slate-200 flex-1" />
</div>
<div className="space-y-6">
<div className="flex gap-2">
<div className="relative flex-1 group">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-slate-900 transition-colors">
<Globe size={18} />
</div>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addReference()}
placeholder="Website URL oder Name (z.B. apple.com)"
className="w-full pl-12 pr-4 py-4 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-4 focus:ring-slate-900/5 focus:border-slate-900 transition-all text-slate-900 placeholder:text-slate-300"
/>
</div>
<button
onClick={addReference}
disabled={!inputValue.trim()}
className="px-6 py-4 bg-slate-900 text-white rounded-xl font-bold hover:bg-slate-800 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Plus size={20} />{" "}
<span className="hidden md:inline">Hinzufügen</span>
</button>
</div>
<div className="grid grid-cols-1 gap-3">
<AnimatePresence mode="popLayout">
{references.map((ref) => (
<motion.div
key={ref}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
className="flex items-center justify-between p-4 bg-slate-50 border border-slate-100 rounded-xl group hover:border-slate-200 transition-all"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-white border border-slate-200 flex items-center justify-center text-slate-400 group-hover:text-slate-900 transition-colors shadow-sm">
<Link2 size={14} />
</div>
<span className="font-medium text-slate-700">{ref}</span>
</div>
<button
onClick={() => removeReference(ref)}
className="p-2 text-slate-300 hover:text-red-500 transition-colors"
>
<X size={18} />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
{references.length === 0 && (
<div className="py-12 text-center border-2 border-dashed border-slate-100 rounded-2xl">
<p className="text-slate-400 font-medium">
Noch keine Referenzen hinzugefügt.
</p>
<p className="text-slate-300 text-sm mt-1">
Geben Sie eine URL ein, die Ihnen gefällt.
</p>
</div>
)}
</div>
</div>
);
};
@@ -1,182 +0,0 @@
"use client";
import { cn } from "../../utils/cn";
import { Reveal } from "../Reveal";
import { MessageSquareText, Settings2, ArrowRight } from "lucide-react";
import { ProjectType } from "./types";
interface ContactGatewayProps {
name: string;
setName: (val: string) => void;
company: string;
setCompany: (val: string) => void;
projectType: ProjectType;
setProjectType: (val: ProjectType) => void;
onChooseConfigurator: () => void;
onChooseDirectMessage: () => void;
}
const AutoInput = ({
value,
onChange,
placeholder,
autoFocus,
}: {
value: string;
onChange: (val: string) => void;
placeholder: string;
autoFocus?: boolean;
}) => {
return (
<div className="inline-grid items-center relative group mx-1">
<span className="col-start-1 row-start-1 opacity-0 pointer-events-none whitespace-pre border-b-2 border-transparent px-2 py-1 text-2xl md:text-5xl font-bold tracking-tight min-w-[140px]">
{value || placeholder}
</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
autoFocus={autoFocus}
className={cn(
"col-start-1 row-start-1 w-full h-full bg-transparent focus:outline-none px-2 py-1",
"text-2xl md:text-5xl font-bold tracking-tight transition-all duration-300",
"border-b-2",
value
? "text-slate-900 border-slate-900"
: "text-slate-300 border-slate-100 placeholder:text-slate-200 focus:border-green-500 focus:placeholder:text-slate-300",
)}
/>
</div>
);
};
export const ContactGateway = ({
name,
setName,
company,
setCompany,
onChooseConfigurator,
onChooseDirectMessage,
}: ContactGatewayProps) => {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] text-left w-full px-4 max-w-5xl mx-auto space-y-24">
{/* Identity Section */}
<Reveal width="100%" delay={0.1}>
<div className="space-y-4">
<span className="text-[10px] font-mono text-green-600 uppercase tracking-[0.3em] font-bold">
IDENTIFIKATION // SCHRITT_00
</span>
<div className="flex flex-wrap items-baseline gap-y-6 text-slate-900">
<span className="text-2xl md:text-5xl font-medium text-slate-400">
Hi, ich bin
</span>
<AutoInput value={name} onChange={setName} placeholder="Ihr Name" />
<span className="text-2xl md:text-5xl font-medium text-slate-400">
von
</span>
<AutoInput
value={company}
onChange={setCompany}
placeholder="Firma / Projekt"
/>
</div>
</div>
</Reveal>
{/* Path Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
{/* Configurator Path */}
<Reveal width="100%" delay={0.3} direction="up">
<div className="relative group p-[1px] rounded-3xl overflow-hidden transition-all duration-500">
{/* Disabled Overlay Background */}
<div className="absolute inset-0 bg-gradient-to-br from-slate-200 to-slate-100 dark:from-slate-800 dark:to-slate-900 opacity-50" />
<button
disabled
className={cn(
"w-full h-full relative flex flex-col items-start p-8 rounded-[23px] border text-left bg-slate-50 border-slate-100 text-slate-400 cursor-not-allowed",
)}
>
<div className="absolute top-0 right-0 p-8 opacity-5">
<Settings2 size={120} />
</div>
<div className="flex items-center gap-3 mb-6">
<Settings2 size={24} className="text-slate-300" />
<span className="px-2 py-0.5 rounded-full bg-slate-200 text-[8px] font-bold uppercase tracking-wider text-slate-500">
Wartungsmodus
</span>
</div>
<h3 className="text-2xl font-bold mb-2 tracking-tight opacity-50">
System-Konfigurator
</h3>
<p className="text-sm text-slate-400 font-medium mb-8 max-w-[280px] opacity-70">
Dieser Modus wird aktuell optimiert und steht in Kürze wieder
zur Verfügung.
</p>
<div className="mt-auto flex items-center gap-2 text-[10px] font-mono uppercase tracking-widest font-bold opacity-30">
<span>Konfigurator offline</span>
</div>
</button>
</div>
</Reveal>
{/* Direct Mail Path */}
<Reveal width="100%" delay={0.4} direction="up">
<button
onClick={onChooseDirectMessage}
disabled={!name}
className={cn(
"group relative flex flex-col items-start p-8 rounded-3xl border text-left transition-all duration-500 overflow-hidden",
name
? "bg-white border-slate-200 text-slate-900 hover:-translate-y-2 hover:border-slate-400 hover:shadow-xl"
: "bg-slate-50 border-slate-100 text-slate-400 cursor-not-allowed opacity-60",
)}
>
<MessageSquareText
size={24}
className={cn(
"mb-6 transition-colors",
name ? "text-slate-900" : "text-slate-300",
)}
/>
<h3 className="text-2xl font-bold mb-2 tracking-tight">
Direktnachricht
</h3>
<p
className={cn(
"text-sm font-medium mb-8 max-w-[280px]",
name ? "text-slate-500" : "text-slate-400",
)}
>
Kurze Frage oder spezifisches Anliegen? Senden Sie mir direkt
Informationen.
</p>
<div
className={cn(
"mt-auto flex items-center gap-2 text-[10px] font-mono uppercase tracking-widest font-bold transition-colors",
name
? "text-slate-400 group-hover:text-slate-900"
: "text-slate-300",
)}
>
<span>Formular öffnen</span>
<ArrowRight
size={14}
className="group-hover:translate-x-1 transition-transform"
/>
</div>
{!name && (
<div className="absolute inset-0 bg-slate-50/60 backdrop-blur-[6px] z-20" />
)}
</button>
</Reveal>
</div>
</div>
);
};
@@ -1,273 +0,0 @@
"use client";
import { motion } from "framer-motion";
import { cn } from "../../utils/cn";
import { Reveal } from "../Reveal";
import { ProjectType } from "./types";
import {
Mail,
MessageSquare,
ArrowLeft,
Send,
Phone,
User as UserIcon,
Calendar,
Layers,
} from "lucide-react";
interface DirectMessageFlowProps {
name: string;
setName: (val: string) => void;
email: string;
setEmail: (val: string) => void;
phone: string;
setPhone: (val: string) => void;
role: string;
setRole: (val: string) => void;
company: string;
setCompany: (val: string) => void;
projectType: ProjectType;
setProjectType: (val: ProjectType) => void;
deadline: string;
setDeadline: (val: string) => void;
message: string;
setMessage: (val: string) => void;
onBack: () => void;
onSubmit: () => void;
isSubmitting: boolean;
error?: string | null;
}
export const DirectMessageFlow = ({
name,
setName,
email,
setEmail,
phone,
setPhone,
role,
setRole,
company,
setCompany,
projectType,
setProjectType,
deadline,
setDeadline,
message,
setMessage,
onBack,
onSubmit,
isSubmitting,
error,
}: DirectMessageFlowProps) => {
return (
<div className="w-full max-w-3xl mx-auto px-4 py-12">
<div className="space-y-12">
{error && (
<Reveal width="100%" delay={0.1}>
<div className="bg-red-50 border border-red-100 rounded-2xl p-6 flex items-start gap-4 mb-8">
<div className="p-2 bg-red-100 rounded-lg text-red-600">
<Mail size={20} className="animate-pulse" />
</div>
<div className="space-y-1">
<h3 className="text-red-900 font-bold text-sm uppercase tracking-wider">
Übertragungsfehler
</h3>
<p className="text-red-700/80 font-medium text-base">
{error}
</p>
<div className="mt-2 text-[10px] font-mono text-red-400 uppercase tracking-widest">
Status: FEHLER_BEI_SEQUENZ_INIT
</div>
</div>
</div>
</Reveal>
)}
<Reveal width="100%" delay={0.2}>
<div className="space-y-4">
<span className="text-[10px] font-mono text-green-600 uppercase tracking-[0.3em] font-bold">
DIREKTANFRAGE // SCHRITT_01
</span>
<h2 className="text-3xl md:text-5xl font-bold tracking-tight text-slate-900 line-clamp-2">
Lassen Sie uns sprechen.
</h2>
<p className="text-slate-500 font-medium text-lg">
Senden Sie mir eine Nachricht und ich melde mich zeitnah zurück.
</p>
</div>
</Reveal>
<div className="space-y-12">
{/* Section: Mission Focus */}
<Reveal width="100%" delay={0.3} direction="up">
<div className="space-y-6">
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
<Layers size={12} /> Mission // Projekt-Typ
</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{(["website", "web-app", "ecommerce"] as ProjectType[]).map(
(type) => {
const labels = {
website: "Website",
"web-app": "Web Application",
ecommerce: "E-Commerce",
};
return (
<button
key={type}
onClick={() => setProjectType(type)}
className={cn(
"px-6 py-4 rounded-xl border font-bold text-sm transition-all duration-200 text-left",
projectType === type
? "bg-slate-900 text-white border-slate-900 shadow-lg"
: "bg-white border-slate-100 text-slate-500 hover:border-slate-300",
)}
>
{labels[type]}
</button>
);
},
)}
</div>
</div>
</Reveal>
{/* Section: Identity Details */}
<Reveal width="100%" delay={0.4} direction="up">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
<UserIcon size={12} /> Ihr Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Max Mustermann"
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
/>
</div>
<div className="space-y-4">
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
<Layers size={12} /> Unternehmen (Optional)
</label>
<input
type="text"
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="Beispiel GmbH"
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
/>
</div>
<div className="space-y-4">
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
<Mail size={12} /> E-Mail Adresse
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@firma.de"
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
/>
</div>
<div className="space-y-4">
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
<Phone size={12} /> Rückruf-Nummer (Optional)
</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+49 123 456789"
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
/>
</div>
</div>
</Reveal>
{/* Section: Additional Context */}
<Reveal width="100%" delay={0.5} direction="up">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
<UserIcon size={12} /> Ihre Position
</label>
<input
type="text"
value={role}
onChange={(e) => setRole(e.target.value)}
placeholder="z.B. Gründer, Marketing Lead..."
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
/>
</div>
<div className="space-y-4">
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
<Calendar size={12} /> Zeitfenster
</label>
<select
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all appearance-none cursor-pointer"
>
<option value="asap">ASAP (Sofort)</option>
<option value="1month">&lt; 1 Monat (Priorität)</option>
<option value="3months">1-3 Monate (Standard)</option>
<option value="flexible">Flexibel</option>
</select>
</div>
</div>
</Reveal>
{/* Section: Payload */}
<Reveal width="100%" delay={0.6} direction="up">
<div className="space-y-4">
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
<MessageSquare size={12} /> Nachricht // Briefing
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Beschreiben Sie kurz Ihr Anliegen oder hinterlassen Sie einen Link zum Briefing..."
rows={5}
className="w-full bg-slate-50 border border-slate-100 rounded-2xl px-6 py-4 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all resize-none"
/>
</div>
</Reveal>
{/* Submit Button */}
<Reveal width="100%" delay={0.7} direction="up">
<button
onClick={onSubmit}
disabled={isSubmitting || !email || !message || !name}
className={cn(
"group relative w-full py-5 rounded-2xl font-bold text-lg transition-all duration-300 flex items-center justify-center gap-3 overflow-hidden",
isSubmitting || !email || !message || !name
? "bg-slate-100 text-slate-400 cursor-not-allowed"
: "bg-slate-900 text-white shadow-xl hover:shadow-2xl hover:-translate-y-1 active:scale-[0.98]",
)}
>
<div className="absolute inset-0 bg-gradient-to-r from-green-400/0 via-green-400/10 to-green-400/0 opacity-0 group-hover:opacity-100 -translate-x-full group-hover:translate-x-full transition-all duration-1000" />
{isSubmitting ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>Übertragung läuft...</span>
</>
) : (
<>
<span>Anfrage senden</span>
<Send
size={20}
className="group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform"
/>
</>
)}
</button>
</Reveal>
</div>
</div>
</div>
);
};
@@ -1,137 +0,0 @@
import * as React from "react";
export const getInquiryEmailHtml = (data: any) => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Georgia, 'Times New Roman', Times, serif; background-color: #f8fafc; color: #1e293b; margin: 0; padding: 40px 20px; line-height: 1.6; }
.wrapper { max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #e2e8f0; border-radius: 4px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.top-bar { background-color: #0f172a; color: #ffffff; padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; }
.brand { font-family: system-ui, sans-serif; font-size: 10px; font-weight: 800; letter-spacing: 0.2em; text-transform: uppercase; }
.status { font-family: ui-monospace, monospace; font-size: 9px; color: #10b981; text-transform: uppercase; font-weight: bold; }
.header { padding: 40px 32px 24px 32px; border-bottom: 2px solid #f1f5f9; }
.heading { font-family: system-ui, sans-serif; font-size: 24px; font-weight: 800; color: #0f172a; margin: 0; letter-spacing: -0.02em; }
.content { padding: 32px; }
.data-grid { width: 100%; border-collapse: collapse; margin-bottom: 32px; }
.data-row td { padding: 12px 0; border-bottom: 1px solid #f8fafc; vertical-align: top; }
.label { font-family: system-ui, sans-serif; font-size: 9px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.1em; width: 140px; }
.value { font-size: 15px; color: #334155; }
.message-container { background-color: #f8fafc; border: 1px solid #f1f5f9; border-radius: 4px; padding: 24px; margin-top: 12px; }
.message-text { font-family: Georgia, serif; font-size: 16px; color: #1e293b; white-space: pre-wrap; margin: 0; }
.config-code { font-family: ui-monospace, monospace; font-size: 12px; color: #64748b; background: #f1f5f9; padding: 16px; border-radius: 4px; display: block; overflow-x: auto; }
.footer { padding: 32px; color: #94a3b8; font-size: 11px; text-align: center; border-top: 1px solid #f1f5f9; }
.footer-meta { font-family: ui-monospace, monospace; font-size: 9px; margin-top: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
</style>
</head>
<body>
<div class="wrapper">
<div class="top-bar">
<div class="brand">MINTEL_TECHNICAL_OPERATIONS</div>
<div class="status">● INCOMING_INQUIRY</div>
</div>
<div class="header">
<h1 class="heading">${data.isFreeText ? "Direktanfrage" : "System-Konfiguration"}</h1>
<p style="margin: 8px 0 0 0; color: #64748b; font-size: 14px;">Eingang für ${data.companyName || data.name}</p>
</div>
<div class="content">
<table class="data-grid">
<tr class="data-row">
<td class="label">Absender</td>
<td class="value"><strong>${data.name}</strong><br/><span style="color: #94a3b8; font-size: 13px;">${data.email}</span></td>
</tr>
${data.phone ? `
<tr class="data-row">
<td class="label">Telefon</td>
<td class="value">${data.phone}</td>
</tr>` : ""}
${data.role ? `
<tr class="data-row">
<td class="label">Position</td>
<td class="value">${data.role}</td>
</tr>` : ""}
<tr class="data-row">
<td class="label">Unternehmen</td>
<td class="value">${data.companyName || "—"}</td>
</tr>
<tr class="data-row">
<td class="label">Projekt-Typ</td>
<td class="value">${data.projectType}</td>
</tr>
${data.deadline ? `
<tr class="data-row">
<td class="label">Zeitraum</td>
<td class="value">${data.deadline}</td>
</tr>` : ""}
</table>
${data.isFreeText ? `
<div class="label" style="margin-bottom: 8px;">Nachricht</div>
<div class="message-container">
<div class="message-text">${data.message}</div>
</div>
` : `
<div class="label" style="margin-bottom: 8px;">Konfigurations-Daten</div>
<code class="config-code">${JSON.stringify(data.config, null, 2)}</code>
`}
</div>
<div class="footer">
Dies ist eine systemgenerierte Benachrichtigung von mintel.me.<br/>
<div class="footer-meta">
TIMESTAMP: ${new Date().toISOString()} | STATUS: VALIDATED
</div>
</div>
</div>
</body>
</html>
`;
export const getConfirmationEmailHtml = (data: any) => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Georgia, 'Times New Roman', Times, serif; background-color: #ffffff; color: #1e293b; margin: 0; padding: 40px 20px; line-height: 1.8; }
.wrapper { max-width: 600px; margin: 0 auto; }
.logo-container { padding-bottom: 48px; border-bottom: 1px solid #f1f5f9; margin-bottom: 48px; }
.status-badge { display: inline-block; font-family: system-ui, sans-serif; font-size: 9px; font-weight: 800; color: #3b82f6; text-transform: uppercase; letter-spacing: 0.2em; border: 1px solid #dbeafe; background: #eff6ff; padding: 4px 12px; border-radius: 99px; margin-bottom: 24px; }
.greeting { font-family: system-ui, sans-serif; font-size: 32px; font-weight: 800; color: #0f172a; margin: 0 0 16px 0; letter-spacing: -0.03em; }
.body-text { font-size: 18px; color: #334155; margin-bottom: 32px; }
.cta-box { border-left: 2px solid #0f172a; padding: 8px 0 8px 24px; margin: 40px 0; }
.footer { margin-top: 80px; padding-top: 24px; border-top: 1px solid #f1f5f9; font-family: system-ui, sans-serif; font-size: 12px; color: #94a3b8; text-align: center; }
.footer a { color: #0f172a; text-decoration: none; font-weight: 600; }
</style>
</head>
<body>
<div class="wrapper">
<div class="logo-container">
<div class="status-badge">Anfrage eingegangen</div>
<h1 class="greeting">Hallo ${data.name.split(" ")[0]},</h1>
<p class="body-text">
vielen Dank für dein Interesse an einer Zusammenarbeit. Deine Nachricht bezüglich <strong>${data.companyName || "deines Projekts"}</strong> ist sicher bei mir angekommen.
</p>
</div>
<div class="body-text">
Ich werde mir die Details umgehend ansehen und mich in der Regel innerhalb der nächsten 24 Stunden persönlich bei dir zurückmelden, um die nächsten Schritte zu besprechen.
</div>
<div class="cta-box">
<p style="margin:0; font-style: italic; color: #64748b;">
„Technical problems are just puzzles with more moving parts.“
</p>
</div>
<div class="footer">
&copy; ${new Date().getFullYear()} <a href="https://mintel.me">mintel.me</a> — Marc Mintel<br/>
Professional Website Systems & Technical Architecture
</div>
</div>
</body>
</html>
`;
@@ -1,23 +0,0 @@
'use client';
import * as React from 'react';
import { useEffect, useRef } from 'react';
import { useSpring, useTransform } from 'framer-motion';
export function AnimatedNumber({ value }: { value: number }) {
const spring = useSpring(value, { stiffness: 50, damping: 20 });
const display = useTransform(spring, (v) => Math.round(v).toLocaleString());
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
spring.set(value);
}, [value, spring]);
useEffect(() => {
return display.on('change', (v) => {
if (ref.current) ref.current.textContent = v;
});
}, [display]);
return <span ref={ref}>{value.toLocaleString()}</span>;
}
@@ -1,62 +0,0 @@
"use client";
import * as React from "react";
import { Check } from "lucide-react";
import { motion } from "framer-motion";
interface CheckboxProps {
label: string;
desc: string;
checked: boolean;
onChange: () => void;
}
export function Checkbox({ label, desc, checked, onChange }: CheckboxProps) {
return (
<button
type="button"
onClick={onChange}
className={`w-full p-4 rounded-xl border-2 text-left transition-all duration-300 flex items-start gap-3 focus:outline-none overflow-hidden relative ${
checked
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-100 bg-white hover:border-slate-200"
}`}
>
<div
className={`mt-0.5 w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0 transition-all duration-500 ${checked ? "border-white bg-white text-slate-900 scale-110" : "border-slate-200"}`}
>
{checked && (
<motion.div
initial={{ scale: 0, rotate: -45 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
<Check size={14} strokeWidth={4} />
</motion.div>
)}
</div>
<div className="flex-grow">
<h4
className={`text-base font-bold mb-0.5 transition-colors duration-500 ${checked ? "text-white" : "text-slate-900"}`}
>
{label}
</h4>
{desc && (
<p
className={`text-sm leading-relaxed transition-colors duration-500 ${checked ? "text-slate-300" : "text-slate-500"}`}
>
{desc}
</p>
)}
</div>
{checked && (
<motion.div
layoutId={`check-bg-${label}`}
className="absolute inset-0 bg-gradient-to-br from-slate-800 to-slate-900 -z-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
/>
)}
</button>
);
}
@@ -1,46 +0,0 @@
"use client";
import * as React from "react";
import { LucideIcon } from "lucide-react";
interface InputProps extends React.InputHTMLAttributes<
HTMLInputElement | HTMLTextAreaElement
> {
label?: string;
icon?: LucideIcon;
isTextArea?: boolean;
rows?: number;
}
export function Input({
label,
icon: Icon,
isTextArea,
className = "",
...props
}: InputProps) {
const InputComponent = isTextArea ? "textarea" : "input";
return (
<div className="space-y-4 w-full">
{label && (
<label className="block text-sm font-bold uppercase tracking-widest text-slate-400 ml-2">
{label}
</label>
)}
<div className="relative group">
{Icon && (
<div
className={`absolute left-6 ${isTextArea ? "top-10" : "top-1/2"} -translate-y-1/2 text-black transition-colors`}
>
<Icon size={24} />
</div>
)}
<InputComponent
{...(props as any)}
className={`w-full p-8 ${Icon ? "pl-16" : "px-10"} bg-white border border-slate-100 rounded-[2.5rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-xl focus:shadow-2xl ${isTextArea ? "resize-none" : ""} ${className}`}
/>
</div>
</div>
);
}
@@ -1,295 +0,0 @@
"use client";
import * as React from "react";
import { FormState, Totals } from "../types";
import { PRICING } from "../constants";
import { AnimatedNumber } from "./AnimatedNumber";
import { ConceptAutomation } from "../../Landing/ConceptIllustrations";
import { Download, Share2, RefreshCw } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
// EstimationPDF will be imported dynamically where used or inside the and client-side block
import IconWhite from "../../../assets/logo/Icon-White-Transparent.png";
import LogoBlack from "../../../assets/logo/Logo-Black-Transparent.png";
// PDF components removed from top-level dynamic import to fix ESM resolution issues in Next.js 16/Webpack
interface PriceCalculationProps {
state: FormState;
totals: Totals;
isClient: boolean;
_qrCodeData: string;
onShare?: () => void;
}
export function PriceCalculation({
state,
totals,
isClient,
_qrCodeData,
onShare,
}: PriceCalculationProps) {
const {
totalPrice,
monthlyPrice,
totalPagesCount,
totalFeatures,
totalFunctions,
totalApis,
languagesCount,
} = totals;
const totalPages = totalPagesCount;
const [pdfLoading, setPdfLoading] = React.useState(false);
const handleDownload = async () => {
if (pdfLoading) return;
setPdfLoading(true);
try {
const { LocalEstimationPDF } = await import("../pdf/LocalEstimationPDF");
const doc = (
<LocalEstimationPDF
state={state}
totalPrice={totalPrice}
monthlyPrice={monthlyPrice}
totalPagesCount={totalPagesCount}
pricing={PRICING}
headerIcon={
typeof IconWhite === "string" ? IconWhite : (IconWhite as any).src
}
footerLogo={
typeof LogoBlack === "string" ? LogoBlack : (LogoBlack as any).src
}
qrCodeData={_qrCodeData}
/>
);
const { pdf } = await import("@react-pdf/renderer");
// Minimum loading time of 2 seconds for better UX
const [blob] = await Promise.all([
pdf(doc).toBlob(),
new Promise((resolve) => setTimeout(resolve, 2000)),
]);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `kalkulation-${state.name.toLowerCase().replace(/\s+/g, "-") || "projekt"}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error("PDF generation failed:", error);
} finally {
setPdfLoading(false);
}
};
return (
<div className="lg:col-span-4 lg:sticky lg:top-32 self-start z-30">
<div className="bg-slate-50 border border-slate-100 rounded-2xl p-5 space-y-4">
<div className="space-y-4">
{state.projectType === "website" ? (
<>
<div className="space-y-4 overflow-y-auto pr-2 hide-scrollbar max-h-[120px]">
{totalPages > 0 && (
<div className="flex justify-between items-center text-sm">
<span className="text-slate-500">{totalPages}x Seite</span>
<span className="font-medium text-slate-900">
{(totalPages * PRICING.PAGE).toLocaleString()} €
</span>
</div>
)}
{totalFeatures > 0 && (
<div className="flex justify-between items-center text-sm">
<span className="text-slate-500">
{totalFeatures}x System-Modul
</span>
<span className="font-medium text-slate-900">
{(totalFeatures * PRICING.FEATURE).toLocaleString()} €
</span>
</div>
)}
{totalFunctions > 0 && (
<div className="flex justify-between items-center text-sm">
<span className="text-slate-500">
{totalFunctions}x Logik-Funktion
</span>
<span className="font-medium text-slate-900">
{(totalFunctions * PRICING.FUNCTION).toLocaleString()} €
</span>
</div>
)}
{totalApis > 0 && (
<div className="flex justify-between items-center text-sm">
<span className="text-slate-500">
{totalApis}x API Sync
</span>
<span className="font-medium text-slate-900">
{(totalApis * PRICING.API_INTEGRATION).toLocaleString()} €
</span>
</div>
)}
{state.cmsSetup && (
<div className="flex justify-between items-center text-sm">
<span className="text-slate-500">Inhalts-Verwaltung</span>
<span className="font-medium text-slate-900">
{(
Math.max(1, totalFeatures) *
PRICING.CMS_CONNECTION_PER_FEATURE
).toLocaleString()}{" "}
€
</span>
</div>
)}
{state.newDatasets > 0 && (
<div className="flex justify-between items-center text-sm">
<span className="text-slate-500">
{state.newDatasets}x Inhalte einpflegen
</span>
<span className="font-medium text-slate-900">
{(
state.newDatasets * PRICING.NEW_DATASET
).toLocaleString()}{" "}
€
</span>
</div>
)}
{languagesCount > 1 && (
<div className="flex justify-between items-center text-sm text-slate-900 font-bold pt-2 border-t border-slate-100">
<span className="text-slate-500">
Mehrsprachigkeit ({languagesCount}x)
</span>
<span>
+
{(
totalPrice -
totalPrice / (1 + (languagesCount - 1) * 0.2)
).toLocaleString()}{" "}
€
</span>
</div>
)}
</div>
<div className="pt-4 space-y-2">
<div className="flex justify-between items-end">
<span className="text-lg font-bold text-slate-900">
Gesamt
</span>
<div className="text-right">
<div className="text-2xl font-bold tracking-tighter text-slate-900">
<AnimatedNumber value={totalPrice} /> €
</div>
</div>
</div>
</div>
<div className="pt-4 border-t border-slate-200 space-y-4">
<div className="flex justify-between items-center">
<span className="text-slate-600 font-medium text-sm">
Sorglos Betrieb (Hosting + Support)
</span>
<span className="text-base font-bold text-slate-900">
{(monthlyPrice * 12).toLocaleString()} € / Jahr
</span>
</div>
</div>
<div className="pt-4 space-y-4">
{isClient && (
<button
type="button"
disabled={pdfLoading}
onClick={handleDownload}
className="w-full h-14 rounded-full border border-slate-200 text-slate-900 font-bold text-sm uppercase tracking-widest hover:bg-white hover:border-slate-900 transition-all focus:outline-none overflow-hidden relative"
>
<AnimatePresence mode="wait">
{pdfLoading ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 flex items-center justify-center bg-white"
>
<motion.div
className="absolute bottom-0 left-0 h-1 bg-slate-900"
initial={{ width: "0%" }}
animate={{ width: "100%" }}
transition={{ duration: 2, ease: "easeInOut" }}
/>
<span className="flex items-center gap-2">
<RefreshCw className="animate-spin" size={16} />
PDF wird erstellt...
</span>
</motion.div>
) : (
<motion.div
key="idle"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex items-center justify-center gap-3"
>
<Download size={18} />
<span>Als PDF speichern</span>
</motion.div>
)}
</AnimatePresence>
</button>
)}
{onShare && (
<button
type="button"
onClick={onShare}
className="w-full flex items-center justify-center gap-3 rounded-full bg-slate-900 text-white font-bold text-sm uppercase tracking-widest hover:bg-slate-800 transition-all focus:outline-none px-6 py-3"
>
<Share2 size={18} />
<span>Teilen</span>
</button>
)}
</div>
</>
) : (
<div className="space-y-6">
<div className="py-6 text-center space-y-4">
<div className="w-16 h-16 bg-white rounded-full flex items-center justify-center mx-auto border border-slate-100">
<ConceptAutomation className="w-10 h-10 text-black" />
</div>
<div className="space-y-1">
<p className="text-slate-600 text-xs leading-relaxed">
Web Apps werden nach Aufwand abgerechnet.
</p>
<p className="text-2xl font-bold text-slate-900">
{PRICING.APP_HOURLY} €{" "}
<span className="text-base text-slate-400 font-normal">
/ Std.
</span>
</p>
</div>
</div>
{onShare && (
<button
type="button"
onClick={onShare}
className="w-full flex items-center justify-center gap-3 px-6 py-3 rounded-full bg-slate-900 text-white font-bold text-sm uppercase tracking-widest hover:bg-slate-800 transition-all focus:outline-none"
>
<Share2 size={18} />
<span>Teilen</span>
</button>
)}
</div>
)}
</div>
<p className="text-[10px] leading-relaxed text-slate-400 italic text-center">
Ein verbindliches Angebot erstelle ich nach einem persönlichen
Gespräch.
</p>
</div>
</div>
);
}
@@ -1,74 +0,0 @@
'use client';
import * as React from 'react';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Plus, X } from 'lucide-react';
interface RepeatableListProps {
items: string[];
onAdd: (val: string) => void;
onRemove: (index: number) => void;
placeholder: string;
}
export function RepeatableList({
items,
onAdd,
onRemove,
placeholder
}: RepeatableListProps) {
const [input, setInput] = useState('');
return (
<div className="space-y-6">
<div className="flex gap-3">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (input.trim()) {
onAdd(input.trim());
setInput('');
}
}
}}
placeholder={placeholder}
className="flex-1 p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors text-lg"
/>
<button
type="button"
onClick={() => {
if (input.trim()) {
onAdd(input.trim());
setInput('');
}
}}
className="w-20 h-20 rounded-full bg-slate-900 text-white flex items-center justify-center hover:bg-slate-800 transition-colors shrink-0 focus:outline-none"
>
<Plus size={32} />
</button>
</div>
<div className="flex flex-wrap gap-3">
<AnimatePresence>
{items.map((item, i) => (
<motion.div
key={i}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="flex items-center gap-3 px-6 py-3 bg-slate-100 rounded-full text-base font-bold text-slate-700"
>
<span className="truncate max-w-[300px]">{item}</span>
<button type="button" onClick={() => onRemove(i)} className="text-slate-400 hover:text-slate-900 transition-colors focus:outline-none">
<X size={18} />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
);
}
@@ -1,122 +0,0 @@
import * as React from "react";
import { type FormState as _FormState } from "./types";
import {
PRICING as LOGIC_PRICING,
PAGE_SAMPLES as LOGIC_PAGE_SAMPLES,
FEATURE_OPTIONS as LOGIC_FEATURE_OPTIONS,
FUNCTION_OPTIONS as LOGIC_FUNCTION_OPTIONS,
API_OPTIONS as LOGIC_API_OPTIONS,
ASSET_OPTIONS as LOGIC_ASSET_OPTIONS,
EMPLOYEE_OPTIONS as LOGIC_EMPLOYEE_OPTIONS,
SOCIAL_MEDIA_OPTIONS as LOGIC_SOCIAL_MEDIA_OPTIONS,
VIBE_LABELS as LOGIC_VIBE_LABELS,
DEADLINE_LABELS as LOGIC_DEADLINE_LABELS,
ASSET_LABELS as LOGIC_ASSET_LABELS,
FEATURE_LABELS as LOGIC_FEATURE_LABELS,
FUNCTION_LABELS as LOGIC_FUNCTION_LABELS,
API_LABELS as LOGIC_API_LABELS,
SOCIAL_LABELS as LOGIC_SOCIAL_LABELS,
initialState as LOGIC_INITIAL_STATE,
DESIGN_OPTIONS,
} from "../../logic/pricing";
export const PRICING = LOGIC_PRICING;
export const PAGE_SAMPLES = LOGIC_PAGE_SAMPLES;
export const FEATURE_OPTIONS = LOGIC_FEATURE_OPTIONS;
export const FUNCTION_OPTIONS = LOGIC_FUNCTION_OPTIONS;
export const API_OPTIONS = LOGIC_API_OPTIONS;
export const ASSET_OPTIONS = LOGIC_ASSET_OPTIONS;
export const EMPLOYEE_OPTIONS = LOGIC_EMPLOYEE_OPTIONS;
export const SOCIAL_MEDIA_OPTIONS = LOGIC_SOCIAL_MEDIA_OPTIONS;
export const VIBE_LABELS = LOGIC_VIBE_LABELS;
export const DEADLINE_LABELS = LOGIC_DEADLINE_LABELS;
export const ASSET_LABELS = LOGIC_ASSET_LABELS;
export const FEATURE_LABELS = LOGIC_FEATURE_LABELS;
export const FUNCTION_LABELS = LOGIC_FUNCTION_LABELS;
export const API_LABELS = LOGIC_API_LABELS;
export const SOCIAL_LABELS = LOGIC_SOCIAL_LABELS;
export const initialState = LOGIC_INITIAL_STATE;
const VIBE_ILLUSTRATIONS: Record<string, React.ReactNode> = {
minimal: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<rect
x="10"
y="10"
width="80"
height="2"
rx="1"
className="fill-current"
/>
<rect
x="10"
y="20"
width="50"
height="2"
rx="1"
className="fill-current"
/>
<rect
x="10"
y="40"
width="30"
height="10"
rx="1"
className="fill-current"
/>
</svg>
),
bold: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<rect
x="10"
y="10"
width="80"
height="15"
rx="2"
className="fill-current"
/>
<rect
x="10"
y="35"
width="80"
height="15"
rx="2"
className="fill-current"
/>
</svg>
),
nature: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<circle cx="30" cy="30" r="20" className="fill-current" />
<circle cx="70" cy="30" r="15" className="fill-current" />
</svg>
),
tech: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<path
d="M10 10 L90 10 L90 50 L10 50 Z"
fill="none"
stroke="currentColor"
strokeWidth="2"
/>
<path
d="M10 30 L90 30"
stroke="currentColor"
strokeWidth="1"
strokeDasharray="4 2"
/>
<path
d="M50 10 L50 50"
stroke="currentColor"
strokeWidth="1"
strokeDasharray="4 2"
/>
</svg>
),
};
export const DESIGN_VIBES = DESIGN_OPTIONS.map((opt) => ({
...opt,
illustration: VIBE_ILLUSTRATIONS[opt.id],
}));
@@ -1,134 +0,0 @@
"use client";
import * as React from "react";
import {
Page as PDFPage,
Document as PDFDocument,
Image as PDFImage,
StyleSheet,
View as PDFView,
Text as PDFText,
} from "@react-pdf/renderer";
import {
FrontPageModule,
SitemapModule,
EstimationModule,
TransparenzModule,
ClosingModule,
SimpleLayout,
pdfStyles,
calculatePositions,
} from "@mintel/pdf";
// Local styles for QR Code overlay
const styles = StyleSheet.create({
qrContainer: {
position: "absolute",
bottom: 40,
right: 40,
width: 60,
height: 60,
alignItems: "center",
justifyContent: "center",
},
qrImage: {
width: "100%",
height: "100%",
},
qrLabel: {
fontSize: 8,
color: "#94a3b8", // slate-400
marginTop: 4,
textAlign: "center",
},
});
interface PDFProps {
state: any;
totalPrice: number;
monthlyPrice?: number;
totalPagesCount?: number;
pricing: any;
headerIcon?: string;
footerLogo?: string;
qrCodeData?: string;
}
export const LocalEstimationPDF = ({
state,
totalPrice,
pricing,
headerIcon,
footerLogo,
qrCodeData,
}: PDFProps) => {
const date = new Date().toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
});
const positions = calculatePositions(state, pricing);
const companyData = {
name: "Marc Mintel",
address1: "Georg-Meistermann-Straße 7",
address2: "54586 Schüller",
ustId: "DE367588065",
};
const bankData = {
name: "N26",
bic: "NTSBDEB1XXX",
iban: "DE50 1001 1001 2620 4328 65",
};
const commonProps = {
headerIcon: headerIcon,
footerLogo,
companyData,
bankData,
};
let pageCounter = 1;
const getPageNum = () => (pageCounter++).toString().padStart(2, "0");
return (
<PDFDocument title={`Angebot - ${state.companyName || "Projekt"}`}>
<PDFPage size="A4" style={pdfStyles.titlePage}>
<FrontPageModule state={state} headerIcon={headerIcon} date={date} />
{qrCodeData && (
<PDFView style={styles.qrContainer}>
<PDFImage src={qrCodeData} style={styles.qrImage} />
<PDFText style={styles.qrLabel}>Scan me</PDFText>
</PDFView>
)}
</PDFPage>
{/* BriefingModule Page REMOVED as per user request ("die zweite seite ist leer, weg damit") */}
{state.sitemap && state.sitemap.length > 0 && (
<SimpleLayout {...commonProps} showPageNumber={false}>
<SitemapModule state={state} />
</SimpleLayout>
)}
<SimpleLayout {...commonProps} showPageNumber={false}>
<EstimationModule
state={state}
positions={positions}
totalPrice={totalPrice}
date={date}
/>
</SimpleLayout>
<SimpleLayout {...commonProps} showPageNumber={false}>
<TransparenzModule pricing={pricing} />
</SimpleLayout>
<SimpleLayout {...commonProps} showPageNumber={false}>
<ClosingModule />
</SimpleLayout>
</PDFDocument>
);
};
@@ -1,146 +0,0 @@
"use client";
import * as React from "react";
import { FormState } from "../types";
import { Checkbox } from "../components/Checkbox";
import { RepeatableList } from "../components/RepeatableList";
import { Share2, ListPlus } from "lucide-react";
import { motion } from "framer-motion";
import { Reveal } from "../../Reveal";
interface ApiStepProps {
state: FormState;
updateState: (_updates: Partial<FormState>) => void;
toggleItem: (_list: string[], _id: string) => string[];
}
export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
const isWebApp = state.projectType === "web-app";
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter((i) => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
return (
<div className="space-y-12">
<Reveal width="100%" delay={0.1}>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<Share2 size={16} />
</div>
<div className="flex items-center gap-3">
<h4 className="text-lg font-bold text-slate-900">
{isWebApp
? "Integrationen & Datenquellen"
: "Schnittstellen (API)"}
</h4>
<span className="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-bold uppercase tracking-wider rounded">
Optional
</span>
</div>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow("api")}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes("api")
? "bg-slate-900 text-white"
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<p className="text-lg text-slate-500 leading-relaxed ml-2">
{isWebApp
? "Mit welchen Systemen soll die Web App kommunizieren?"
: "Datenaustausch mit Drittsystemen zur Automatisierung."}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{
id: "crm_erp",
label: "CRM / ERP",
desc: "HubSpot, Salesforce, SAP, Xentral etc.",
},
{
id: "payment",
label: "Payment",
desc: "Stripe, PayPal, Klarna Integration.",
},
{
id: "marketing",
label: "Marketing",
desc: "Newsletter (Mailchimp), Social Media Sync.",
},
{
id: "ecommerce",
label: "E-Commerce",
desc: "Shopify, WooCommerce, Lagerbestand-Sync.",
},
{
id: "maps",
label: "Google Maps / Places",
desc: "Standortsuche und Kartenintegration.",
},
].map((opt, index) => (
<motion.div
key={opt.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Checkbox
label={opt.label}
desc={opt.desc}
checked={state.apiSystems.includes(opt.id)}
onChange={() =>
updateState({
apiSystems: toggleItem(state.apiSystems, opt.id),
})
}
/>
</motion.div>
))}
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.2}>
<div className="space-y-12">
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<ListPlus size={16} />
</div>
<h4 className="text-lg font-bold text-slate-900">
Weitere Systeme oder eigene APIs?
</h4>
</div>
<RepeatableList
items={state.otherTech}
onAdd={(v) => updateState({ otherTech: [...state.otherTech, v] })}
onRemove={(i) => updateTech(i)}
placeholder="z.B. Microsoft Graph, Google Search Console, Custom REST API..."
/>
</div>
</div>
</Reveal>
</div>
);
function updateTech(index: number) {
updateState({
otherTech: state.otherTech.filter((_, idx) => idx !== index),
});
}
}
@@ -1,119 +0,0 @@
"use client";
import * as React from "react";
import { FormState } from "../types";
import { ASSET_OPTIONS } from "../constants";
import { Checkbox } from "../components/Checkbox";
import { RepeatableList } from "../components/RepeatableList";
import { motion } from "framer-motion";
import { Briefcase, ListPlus } from "lucide-react";
import { Reveal } from "../../Reveal";
interface AssetsStepProps {
state: FormState;
updateState: (_updates: Partial<FormState>) => void;
toggleItem: (_list: string[], _id: string) => string[];
}
export function AssetsStep({
state,
updateState,
toggleItem,
}: AssetsStepProps) {
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter((i) => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
return (
<div className="space-y-12">
<Reveal width="100%" delay={0.1}>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<Briefcase size={16} />
</div>
<h4 className="text-lg font-bold text-slate-900">
Vorhandene Assets
</h4>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow("assets")}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes("assets")
? "bg-slate-900 text-white"
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{ASSET_OPTIONS.map((opt, index) => (
<motion.div
key={opt.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<div className="relative">
<Checkbox
key={opt.id}
label={opt.label}
desc={opt.desc}
checked={state.assets.includes(opt.id)}
onChange={() =>
updateState({ assets: toggleItem(state.assets, opt.id) })
}
/>
{["logo", "styleguide", "content_concept"].includes(
opt.id,
) && (
<div className="absolute top-4 right-4 px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">
Empfohlen
</div>
)}
</div>
</motion.div>
))}
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.2}>
<div className="space-y-12">
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<ListPlus size={16} />
</div>
<h4 className="text-lg font-bold text-slate-900">
Weitere vorhandene Unterlagen?
</h4>
</div>
<RepeatableList
items={state.otherAssets}
onAdd={(v) =>
updateState({ otherAssets: [...state.otherAssets, v] })
}
onRemove={(i) =>
updateState({
otherAssets: state.otherAssets.filter((_, idx) => idx !== i),
})
}
placeholder="z.B. Lastenheft, Wireframes, Bilddatenbank..."
/>
</div>
</div>
</Reveal>
</div>
);
}
@@ -1,226 +0,0 @@
"use client";
import * as React from "react";
import { FormState } from "../types";
import { Checkbox } from "../components/Checkbox";
import { RepeatableList } from "../components/RepeatableList";
import { motion, AnimatePresence } from "framer-motion";
import {
Minus,
Plus,
FileText,
ListPlus,
HelpCircle,
ArrowRight,
} from "lucide-react";
import { Input } from "../components/Input";
interface BaseStepProps {
state: FormState;
updateState: (_updates: Partial<FormState>) => void;
toggleItem: (_list: string[], _id: string) => string[];
}
export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter((i) => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
return (
<div className="space-y-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-6"
>
<div className="relative">
<Input
label="Thema der Website"
placeholder="z.B. Portfolio für Architektur, Onlineshop für Bio-Tee..."
value={state.websiteTopic}
onChange={(e) => updateState({ websiteTopic: e.target.value })}
/>
<div className="absolute top-0 right-4 px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">
Essenziell
</div>
</div>
</motion.div>
<div className="space-y-4">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-slate-900 text-white rounded-xl flex items-center justify-center shadow-sm">
<FileText size={16} />
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="text-lg font-bold text-slate-900 tracking-tight">
Die Seitenstruktur
</h4>
<span className="px-2 py-0.5 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">
Essenziell
</span>
</div>
<p className="text-sm text-slate-400 mt-0.5">
Wählen Sie die Bausteine Ihrer neuen Website.
</p>
</div>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow("pages")}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes("pages")
? "bg-slate-900 text-white"
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[
{
id: "Home",
label: "Startseite",
desc: "Der erste Eindruck Ihrer Marke.",
},
{
id: "About",
label: "Über uns",
desc: "Ihre Geschichte und Ihr Team.",
},
{
id: "Services",
label: "Leistungen",
desc: "Übersicht Ihres Angebots.",
},
{
id: "Contact",
label: "Kontakt",
desc: "Anlaufstelle für Ihre Kunden.",
},
{
id: "Landing",
label: "Landingpage",
desc: "Optimiert für Marketing-Kampagnen.",
},
{
id: "Legal",
label: "Rechtliches",
desc: "Impressum & Datenschutz.",
},
].map((opt, index) => (
<motion.div
key={opt.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Checkbox
label={opt.label}
desc={opt.desc}
checked={state.selectedPages.includes(opt.id)}
onChange={() =>
updateState({
selectedPages: toggleItem(state.selectedPages, opt.id),
})
}
/>
</motion.div>
))}
</div>
</div>
<div className="space-y-12">
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<ListPlus size={16} />
</div>
<h4 className="text-lg font-bold text-slate-900">
Weitere individuelle Seiten?
</h4>
</div>
<RepeatableList
items={state.otherPages}
onAdd={(v) => updateState({ otherPages: [...state.otherPages, v] })}
onRemove={(i) =>
updateState({
otherPages: state.otherPages.filter((_, idx) => idx !== i),
})
}
placeholder="z.B. Karriere, FAQ, Team-Detail..."
/>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="p-6 bg-slate-900 text-white rounded-2xl space-y-4 shadow-lg relative overflow-hidden group"
>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<ListPlus size={60} />
</div>
<div className="flex flex-col md:flex-row justify-between items-center gap-4 relative z-10">
<div>
<h4 className="text-lg font-bold text-white">
Noch mehr Seiten?
</h4>
<p className="text-sm text-slate-400 mt-0.5">
Falls Sie die Namen noch nicht wissen, aber die Menge schätzen
können.
</p>
</div>
<div className="flex items-center gap-4">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() =>
updateState({
otherPagesCount: Math.max(0, state.otherPagesCount - 1),
})
}
className="w-10 h-10 rounded-full bg-white/10 border border-white/10 flex items-center justify-center hover:bg-white/20 transition-colors focus:outline-none"
>
<Minus size={18} />
</motion.button>
<AnimatePresence mode="wait">
<motion.span
key={state.otherPagesCount}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.5 }}
className="text-3xl font-bold w-10 text-center"
>
{state.otherPagesCount}
</motion.span>
</AnimatePresence>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() =>
updateState({ otherPagesCount: state.otherPagesCount + 1 })
}
className="w-10 h-10 rounded-full bg-white/10 border border-white/10 flex items-center justify-center hover:bg-white/20 transition-colors focus:outline-none"
>
<Plus size={18} />
</motion.button>
</div>
</div>
</motion.div>
</div>
</div>
);
}
@@ -1,71 +0,0 @@
"use client";
import * as React from "react";
import { FormState } from "../types";
import { EMPLOYEE_OPTIONS } from "../constants";
import { motion } from "framer-motion";
import { Building2, Users } from "lucide-react";
import { Reveal } from "../../Reveal";
import { Input } from "../components/Input";
interface CompanyStepProps {
state: FormState;
updateState: (_updates: Partial<FormState>) => void;
}
export function CompanyStep({ state, updateState }: CompanyStepProps) {
return (
<div className="space-y-6">
<Reveal width="100%" delay={0.1}>
<div className="space-y-4" id="focus-target-company">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<Building2 size={16} />
</div>
<h4 className="text-lg font-bold text-slate-900">Unternehmen</h4>
<span className="px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">
Erforderlich
</span>
</div>
<Input
label="Name des Unternehmens"
placeholder="z.B. Muster GmbH"
value={state.companyName}
onChange={(e) => updateState({ companyName: e.target.value })}
/>
</div>
</Reveal>
<Reveal width="100%" delay={0.2}>
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<Users size={16} />
</div>
<h4 className="text-lg font-bold text-slate-900">
Mitarbeiteranzahl
</h4>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{EMPLOYEE_OPTIONS.map((option) => (
<motion.button
key={option.id}
whileHover={{ y: -3 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => updateState({ employeeCount: option.id })}
className={`p-4 rounded-xl border-2 transition-all duration-300 font-bold text-sm ${
state.employeeCount === option.id
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-100 bg-white hover:border-slate-300 text-slate-600"
}`}
>
{option.label}
</motion.button>
))}
</div>
</div>
</Reveal>
</div>
);
}
@@ -1,219 +0,0 @@
"use client";
import * as React from "react";
import { FormState } from "../types";
import {
FileText,
Upload,
X,
User,
Mail,
Briefcase,
MessageSquare,
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { Reveal } from "../../Reveal";
import { Input } from "../components/Input";
interface ContactStepProps {
state: FormState;
updateState: (_updates: Partial<FormState>) => void;
}
export function ContactStep({ state, updateState }: ContactStepProps) {
return (
<div className="space-y-12">
<Reveal width="100%" delay={0.05}>
<div className="p-8 bg-slate-50 text-slate-900 rounded-[2.5rem] mb-8 border border-slate-100">
<h4 className="text-2xl font-bold mb-2">Fast geschafft! 🚀</h4>
<p className="text-slate-500 text-lg">
Ich habe alle Details für das Projekt von{" "}
<span className="text-slate-900 font-bold">
{state.companyName || "Ihrem Unternehmen"}
</span>{" "}
erhalten. Hinterlassen Sie mir noch Ihre Kontaktdaten, damit ich
Ihnen ein konkretes Angebot erstellen kann.
</p>
</div>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<Reveal width="100%" delay={0.1}>
<div className="relative">
<Input
label="Ihr Name"
icon={User}
placeholder="Max Mustermann"
required
value={state.name}
onChange={(e) => updateState({ name: e.target.value })}
/>
<div className="absolute top-0 right-4 px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">
Erforderlich
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.1}>
<div className="relative">
<Input
label="Ihre Email"
icon={Mail}
type="email"
placeholder="max@beispiel.de"
required
value={state.email}
onChange={(e) => updateState({ email: e.target.value })}
/>
<div className="absolute top-0 right-4 px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">
Erforderlich
</div>
</div>
</Reveal>
</div>
<Reveal width="100%" delay={0.2}>
<Input
label="Ihre Rolle"
icon={Briefcase}
placeholder="z.B. CEO, Marketing Manager..."
value={state.role}
onChange={(e) => updateState({ role: e.target.value })}
/>
</Reveal>
<Reveal width="100%" delay={0.3}>
<Input
label="Nachricht"
icon={MessageSquare}
isTextArea
rows={5}
placeholder="Erzählen Sie mir kurz von Ihrem Projekt..."
value={state.message}
onChange={(e) => updateState({ message: e.target.value })}
/>
</Reveal>
<Reveal width="100%" delay={0.4}>
<div className="space-y-6">
<p className="text-lg font-bold text-slate-900 ml-2">
Dateien hochladen (optional)
</p>
<div
className={`relative group border-2 border-dashed rounded-[3rem] p-12 transition-all duration-500 flex flex-col items-center justify-center gap-6 cursor-pointer min-h-[250px] ${
state.contactFiles.length > 0
? "border-slate-900 bg-slate-50"
: "border-slate-200 hover:border-slate-400 bg-white hover:shadow-xl"
}`}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files);
if (files.length > 0)
updateState({
contactFiles: [...state.contactFiles, ...files],
});
}}
onClick={() => document.getElementById("contact-upload")?.click()}
>
<input
id="contact-upload"
type="file"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files ? Array.from(e.target.files) : [];
if (files.length > 0)
updateState({
contactFiles: [...state.contactFiles, ...files],
});
}}
/>
<AnimatePresence mode="wait">
{state.contactFiles.length > 0 ? (
<motion.div
key="files"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="w-full space-y-4"
>
{state.contactFiles.map((file, idx) => (
<motion.div
key={`${file.name}-${idx}`}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
className="flex items-center justify-between p-5 bg-white border border-slate-100 rounded-2xl group/file"
>
<div className="flex items-center gap-4 text-slate-900">
<div className="w-10 h-10 bg-slate-50 rounded-xl flex items-center justify-center text-slate-400 group-hover/file:text-slate-900 transition-colors">
<FileText size={20} />
</div>
<div className="flex flex-col">
<span className="font-bold text-base truncate max-w-[250px]">
{file.name}
</span>
<span className="text-[10px] text-slate-400 uppercase font-bold">
{(file.size / 1024 / 1024).toFixed(2)} MB
</span>
</div>
</div>
<motion.button
whileHover={{
scale: 1.1,
backgroundColor: "#fee2e2",
color: "#ef4444",
}}
whileTap={{ scale: 0.9 }}
type="button"
onClick={(e) => {
e.stopPropagation();
updateState({
contactFiles: state.contactFiles.filter(
(_, i) => i !== idx,
),
});
}}
className="p-2 bg-slate-50 text-slate-400 rounded-full transition-colors focus:outline-none"
>
<X size={20} />
</motion.button>
</motion.div>
))}
<p className="text-xs text-slate-400 text-center mt-8 font-medium">
Klicken oder ziehen, um weitere Dateien hinzuzufügen
</p>
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center gap-6"
>
<div className="w-20 h-20 bg-slate-50 rounded-[2rem] flex items-center justify-center text-slate-400 group-hover:text-slate-900 group-hover:scale-110 transition-all duration-500">
<Upload size={32} />
</div>
<div className="text-center">
<p className="text-xl font-bold text-slate-900">
Dateien hierher ziehen
</p>
<p className="text-lg text-slate-500 mt-1">
oder klicken zum Auswählen
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</Reveal>
</div>
);
}
@@ -1,230 +0,0 @@
"use client";
import * as React from "react";
import { FormState } from "../types";
import {
Zap,
AlertCircle,
Minus,
Plus,
Settings2,
BarChart3,
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { Reveal } from "../../Reveal";
interface ContentStepProps {
state: FormState;
updateState: (_updates: Partial<FormState>) => void;
}
export function ContentStep({ state, updateState }: ContentStepProps) {
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter((i) => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
return (
<div className="space-y-6">
<Reveal width="100%" delay={0.1}>
<div className="flex flex-col md:flex-row items-center justify-between p-6 bg-white border border-slate-100 rounded-2xl gap-4">
<div className="max-w-2xl space-y-4">
<div className="flex items-center gap-4">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<Settings2 size={16} />
</div>
<h4 className="text-lg font-bold text-slate-900">
Inhalte selbst verwalten (CMS)
</h4>
</div>
<p className="text-lg text-slate-500 leading-relaxed">
Ein CMS (Content Management System) ermöglicht es Ihnen, Texte,
Bilder und Blogartikel selbst zu ändern, ohne programmieren zu
müssen. Ideal, wenn Sie Ihre Website aktuell halten möchten.
</p>
</div>
<div className="flex flex-col items-center md:items-end gap-6">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow("cms")}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all ${
state.dontKnows?.includes("cms")
? "bg-slate-900 text-white"
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
}`}
>
Ich weiß es nicht
</motion.button>
<button
type="button"
onClick={() => updateState({ cmsSetup: !state.cmsSetup })}
className={`w-24 h-12 rounded-full transition-all duration-500 relative focus:outline-none ${state.cmsSetup ? "bg-slate-900" : "bg-slate-200"}`}
>
<motion.div
animate={{ x: state.cmsSetup ? 48 : 0 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="absolute top-1.5 left-1.5 w-9 h-9 bg-white rounded-full"
/>
</button>
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.2}>
<div className="p-10 bg-slate-50 rounded-2xl border border-slate-100 space-y-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-white rounded-2xl flex items-center justify-center text-black">
<BarChart3 size={16} />
</div>
<p className="text-xl font-bold text-slate-900">
Wie oft ändern sich Ihre Inhalte?
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ id: "low", label: "Selten", desc: "Wenige Male im Jahr." },
{
id: "medium",
label: "Regelmäßig",
desc: "Monatliche Updates.",
},
{
id: "high",
label: "Häufig",
desc: "Wöchentlich oder täglich.",
},
].map((opt, index) => (
<motion.button
key={opt.id}
whileHover={{ y: -5 }}
whileTap={{ scale: 0.98 }}
type="button"
onClick={() => updateState({ expectedAdjustments: opt.id })}
className={`p-6 rounded-xl border-2 text-left transition-all duration-300 focus:outline-none ${
state.expectedAdjustments === opt.id
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white hover:border-slate-400"
}`}
>
<p
className={`font-bold text-lg ${state.expectedAdjustments === opt.id ? "text-white" : "text-slate-900"}`}
>
{opt.label}
</p>
<p
className={`text-sm mt-2 leading-relaxed ${state.expectedAdjustments === opt.id ? "text-slate-200" : "text-slate-500"}`}
>
{opt.desc}
</p>
</motion.button>
))}
</div>
<AnimatePresence>
{state.expectedAdjustments === "high" && !state.cmsSetup && (
<motion.div
initial={{ opacity: 0, height: 0, y: 20 }}
animate={{ opacity: 1, height: "auto", y: 0 }}
exit={{ opacity: 0, height: 0, y: 20 }}
className="p-8 bg-amber-50 rounded-[2.5rem] border border-amber-100 flex gap-6 items-start"
>
<div className="w-12 h-12 bg-white rounded-xl flex items-center justify-center text-amber-600 shrink-0">
<AlertCircle size={16} />
</div>
<div className="space-y-2">
<p className="text-amber-900 text-xl font-bold">
Empfehlung: CMS nutzen
</p>
<p className="text-amber-800 text-base leading-relaxed max-w-3xl">
Bei täglichen oder wöchentlichen Änderungen sparen Sie mit
einem CMS langfristig viel Geld, da Sie keine externen
Entwickler für Inhalts-Updates benötigen.
</p>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
<div className="p-8 bg-white rounded-[2.5rem] border border-slate-100 space-y-4">
<div className="flex items-center gap-3 text-slate-900 font-bold text-sm uppercase tracking-[0.2em]">
<Zap size={18} /> Vorteil CMS
</div>
<p className="text-base text-slate-500 leading-relaxed">
Volle Kontrolle über Ihre Inhalte und keine laufenden Kosten für
kleine Textänderungen oder neue Blog-Beiträge.
</p>
</div>
<div className="p-8 bg-white rounded-[2.5rem] border border-slate-100 space-y-4">
<div className="flex items-center gap-3 text-slate-900 font-bold text-sm uppercase tracking-[0.2em]">
<AlertCircle size={18} /> Fokus Design
</div>
<p className="text-base text-slate-500 leading-relaxed">
Ohne CMS bleibt die technische Komplexität geringer und das
Design ist maximal geschützt vor ungewollten Änderungen.
</p>
</div>
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.3}>
<div className="flex flex-col gap-4 p-6 bg-white border border-slate-100 rounded-2xl">
<div className="space-y-2">
<h4 className="text-lg font-bold text-slate-900">
Inhalte einpflegen
</h4>
<p className="text-lg text-slate-500 leading-relaxed">
Wer kümmert sich um die erste Befüllung? Wenn ich das übernehmen
soll, geben Sie hier die Anzahl der Datensätze (z.B. fertige
Blogartikel oder Produkte) an. Ansonsten übergeben wir Ihnen eine
leere, aber einsatzbereite Struktur.
</p>
</div>
<div className="flex items-center gap-12 py-2">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() =>
updateState({ newDatasets: Math.max(0, state.newDatasets - 1) })
}
className="w-16 h-16 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"
>
<Minus size={28} />
</motion.button>
<AnimatePresence mode="wait">
<motion.span
key={state.newDatasets}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="text-7xl font-bold w-20 text-center tabular-nums"
>
{state.newDatasets}
</motion.span>
</AnimatePresence>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() =>
updateState({ newDatasets: state.newDatasets + 1 })
}
className="w-16 h-16 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"
>
<Plus size={28} />
</motion.button>
</div>
</div>
</Reveal>
</div>
);
}
@@ -1,295 +0,0 @@
"use client";
import * as React from "react";
import { FormState } from "../types";
import { DESIGN_VIBES } from "../constants";
import { motion, AnimatePresence } from "framer-motion";
import { Plus, X, Palette, Pipette, RefreshCw } from "lucide-react";
import { Reveal } from "../../Reveal";
import { Input } from "../components/Input";
import { RepeatableList } from "../components/RepeatableList";
interface DesignStepProps {
state: FormState;
updateState: (_updates: Partial<FormState>) => void;
}
export function DesignStep({ state, updateState }: DesignStepProps) {
const addColor = () => {
if (state.colorScheme.length < 5) {
updateState({ colorScheme: [...state.colorScheme, "#000000"] });
}
};
const removeColor = (index: number) => {
if (state.colorScheme.length > 1) {
const newScheme = [...state.colorScheme];
newScheme.splice(index, 1);
updateState({ colorScheme: newScheme });
}
};
const updateColor = (index: number, value: string) => {
const newScheme = [...state.colorScheme];
newScheme[index] = value;
updateState({ colorScheme: newScheme });
};
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter((i) => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
const generateHarmonicPalette = () => {
const hue = Math.floor(Math.random() * 360);
const saturation = 40 + Math.floor(Math.random() * 40);
const lightness = 40 + Math.floor(Math.random() * 40);
const hslToHex = (h: number, s: number, l: number) => {
l /= 100;
const a = (s * Math.min(l, 1 - l)) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color)
.toString(16)
.padStart(2, "0");
};
return `#${f(0)}${f(8)}${f(4)}`;
};
const count = state.colorScheme.length;
const palette = [];
for (let i = 0; i < count; i++) {
const h = (hue + i * (360 / count)) % 360;
const l = i === 0 ? 95 : i === count - 1 ? 20 : lightness;
palette.push(hslToHex(h, saturation, l));
}
updateState({ colorScheme: palette });
};
return (
<div className="space-y-6">
{/* Design Vibe */}
<Reveal width="100%" delay={0.1}>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div className="space-y-1">
<h4 className="text-lg font-bold text-slate-900">
Design-Richtung
</h4>
<p className="text-slate-500">
Welche Ästhetik passt zu Ihrer Marke?
</p>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow("design_vibe")}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes("design_vibe")
? "bg-slate-900 text-white"
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{DESIGN_VIBES.map((vibe, index) => (
<motion.button
key={vibe.id}
whileHover={{ y: -5 }}
type="button"
onClick={() => updateState({ designVibe: vibe.id })}
className={`p-8 rounded-[2.5rem] border-2 text-left transition-all duration-500 focus:outline-none overflow-hidden relative group ${
state.designVibe === vibe.id
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-100 bg-white hover:border-slate-300"
}`}
>
<div
className={`w-16 h-10 mb-6 transition-transform duration-500 group-hover:scale-110 ${state.designVibe === vibe.id ? "text-white" : "text-black"}`}
>
{vibe.illustration}
</div>
<h4
className={`text-2xl font-bold mb-3 ${state.designVibe === vibe.id ? "text-white" : "text-slate-900"}`}
>
{vibe.label}
</h4>
<p
className={`text-lg leading-relaxed ${state.designVibe === vibe.id ? "text-slate-200" : "text-slate-500"}`}
>
{vibe.desc}
</p>
{state.designVibe === vibe.id && (
<motion.div
layoutId="activeVibe"
className="absolute top-4 right-4 w-3 h-3 bg-white rounded-full"
/>
)}
</motion.button>
))}
</div>
</div>
</Reveal>
{/* Color Scheme */}
<Reveal width="100%" delay={0.2}>
<div className="space-y-12">
<div className="flex justify-between items-center">
<div className="space-y-1">
<h4 className="text-lg font-bold text-slate-900">Farbschema</h4>
<p className="text-slate-500">
Definieren Sie Ihre Markenfarben oder lassen Sie sich
inspirieren.
</p>
</div>
<div className="flex items-center gap-4">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={generateHarmonicPalette}
className="flex items-center gap-2 px-6 py-3 rounded-full text-sm font-bold bg-white border border-slate-200 text-slate-900 hover:border-slate-900 transition-all"
>
<RefreshCw size={16} />
Zufall
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow("color_scheme")}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes("color_scheme")
? "bg-slate-900 text-white"
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
}`}
>
Ich weiß es nicht
</motion.button>
</div>
</div>
{/* Custom Picker */}
<div className="space-y-6 p-6 bg-slate-50 rounded-2xl border border-slate-100">
<div className="flex items-center gap-3 text-slate-400 font-bold uppercase tracking-widest text-xs">
<Pipette size={16} />
Individuelle Farben
</div>
<div className="flex flex-wrap gap-6">
<AnimatePresence mode="popLayout">
{state.colorScheme.map((color, i) => (
<motion.div
key={`${i}-${color}`}
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="relative group"
>
<div className="relative w-24 h-24 rounded-3xl overflow-hidden border-2 border-white group-hover:scale-105 transition-transform duration-300">
<input
type="color"
value={color}
onChange={(e) => updateColor(i, e.target.value)}
className="absolute inset-[-100%] w-[300%] h-[300%] cursor-pointer outline-none border-none appearance-none bg-transparent"
/>
<div className="absolute inset-0 pointer-events-none border border-black/5 rounded-3xl" />
</div>
<div className="mt-2 text-center font-mono text-[10px] text-slate-400 uppercase">
{color}
</div>
{state.colorScheme.length > 1 && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() => removeColor(i)}
className="absolute -top-3 -right-3 w-8 h-8 bg-white text-red-500 rounded-full flex items-center justify-center border border-slate-100 opacity-0 group-hover:opacity-100 transition-all duration-300 z-10"
>
<X size={16} strokeWidth={3} />
</motion.button>
)}
</motion.div>
))}
</AnimatePresence>
{state.colorScheme.length < 5 && (
<motion.button
layout
whileHover={{
scale: 1.05,
borderColor: "#0f172a",
color: "#0f172a",
}}
whileTap={{ scale: 0.95 }}
type="button"
onClick={addColor}
className="w-24 h-24 rounded-3xl border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 transition-all duration-300 bg-white/50 hover:bg-white"
>
<Plus size={32} />
<span className="text-[10px] font-bold uppercase mt-1">
Add
</span>
</motion.button>
)}
</div>
<p className="text-sm text-slate-400 font-medium">
Klicken Sie auf eine Farbe, um sie anzupassen. Sie können bis zu 5
Farben definieren.
</p>
</div>
</div>
</Reveal>
{/* References */}
<Reveal width="100%" delay={0.3}>
<div className="space-y-6">
<div className="space-y-1">
<h4 className="text-lg font-bold text-slate-900">
Referenz-Websites
</h4>
<p className="text-slate-500">
Gibt es Websites, die Ihnen besonders gut gefallen?
</p>
</div>
<div className="p-10 bg-white border border-slate-100 rounded-2xl">
<RepeatableList
items={state.references || []}
onAdd={(v) =>
updateState({ references: [...(state.references || []), v] })
}
onRemove={(i) =>
updateState({
references: (state.references || []).filter(
(_, idx) => idx !== i,
),
})
}
placeholder="https://beispiel.de"
/>
</div>
</div>
</Reveal>
{/* Wishes */}
<Reveal width="100%" delay={0.4}>
<Input
label="Individuelle Wünsche"
isTextArea
rows={4}
placeholder="Haben Sie weitere konkrete Vorstellungen?"
value={state.designWishes}
onChange={(e) => updateState({ designWishes: e.target.value })}
/>
</Reveal>
</div>
);
}
@@ -1,120 +0,0 @@
"use client";
import * as React from "react";
import { FormState } from "../types";
import { FEATURE_OPTIONS } from "../constants";
import { Checkbox } from "../components/Checkbox";
import { RepeatableList } from "../components/RepeatableList";
import { motion, AnimatePresence } from "framer-motion";
import { Minus, Plus, LayoutGrid, ListPlus, HelpCircle } from "lucide-react";
interface FeaturesStepProps {
state: FormState;
updateState: (_updates: Partial<FormState>) => void;
toggleItem: (_list: string[], _id: string) => string[];
}
export function FeaturesStep({
state,
updateState,
toggleItem,
}: FeaturesStepProps) {
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter((i) => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
return (
<div className="space-y-6">
<div className="space-y-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<LayoutGrid size={16} />
</div>
<div>
<div className="flex items-center gap-3">
<h4 className="text-lg font-bold text-slate-900">
System-Module
</h4>
<span className="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-bold uppercase tracking-wider rounded">
Optional
</span>
</div>
<div className="flex items-center gap-2 text-slate-400 mt-1">
<HelpCircle size={14} />
<span className="text-sm">
Module sind funktionale Einheiten, die über einfache
Textseiten hinausgehen.
</span>
</div>
</div>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow("features")}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes("features")
? "bg-slate-900 text-white"
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{FEATURE_OPTIONS.map((opt, index) => (
<motion.div
key={opt.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Checkbox
label={opt.label}
desc={opt.desc}
checked={state.features.includes(opt.id)}
onChange={() =>
updateState({ features: toggleItem(state.features, opt.id) })
}
/>
</motion.div>
))}
</div>
</div>
<div className="space-y-12">
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<ListPlus size={16} />
</div>
<h4 className="text-lg font-bold text-slate-900">
Weitere inhaltliche Module?
</h4>
</div>
<RepeatableList
items={state.otherFeatures}
onAdd={(v) =>
updateState({ otherFeatures: [...state.otherFeatures, v] })
}
onRemove={(i) =>
updateState({
otherFeatures: state.otherFeatures.filter(
(_, idx) => idx !== i,
),
})
}
placeholder="z.B. Glossar, Download-Center, Partner-Bereich..."
/>
</div>
</div>
</div>
);
}
@@ -1,218 +0,0 @@
"use client";
import * as React from "react";
import { FormState } from "../types";
import { Checkbox } from "../components/Checkbox";
import { RepeatableList } from "../components/RepeatableList";
import { motion, AnimatePresence } from "framer-motion";
import { Minus, Plus, Cpu, ListPlus } from "lucide-react";
import { Reveal } from "../../Reveal";
interface FunctionsStepProps {
state: FormState;
updateState: (_updates: Partial<FormState>) => void;
toggleItem: (_list: string[], _id: string) => string[];
}
export function FunctionsStep({
state,
updateState,
toggleItem,
}: FunctionsStepProps) {
const isWebApp = state.projectType === "web-app";
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter((i) => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
return (
<div className="space-y-12">
<Reveal width="100%" delay={0.1}>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<Cpu size={16} />
</div>
<h4 className="text-lg font-bold text-slate-900">
{isWebApp
? "Funktionale Anforderungen"
: "Erweiterte Funktionen"}
</h4>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow("functions")}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes("functions")
? "bg-slate-900 text-white"
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{isWebApp ? (
<>
<Checkbox
label="Dashboard & Analytics"
desc="Visualisierung von Daten und Kennzahlen."
checked={state.functions.includes("dashboard")}
onChange={() =>
updateState({
functions: toggleItem(state.functions, "dashboard"),
})
}
/>
<Checkbox
label="Dateiverwaltung"
desc="Upload, Download und Organisation von Dokumenten."
checked={state.functions.includes("files")}
onChange={() =>
updateState({
functions: toggleItem(state.functions, "files"),
})
}
/>
<Checkbox
label="Benachrichtigungen"
desc="E-Mail, Push oder In-App Alerts."
checked={state.functions.includes("notifications")}
onChange={() =>
updateState({
functions: toggleItem(state.functions, "notifications"),
})
}
/>
<Checkbox
label="Export-Funktionen"
desc="CSV, Excel oder PDF Generierung."
checked={state.functions.includes("export")}
onChange={() =>
updateState({
functions: toggleItem(state.functions, "export"),
})
}
/>
</>
) : (
<>
<Checkbox
label="Suche"
desc="Volltextsuche über alle Inhalte."
checked={state.functions.includes("search")}
onChange={() =>
updateState({
functions: toggleItem(state.functions, "search"),
})
}
/>
<Checkbox
label="Filter-Systeme"
desc="Kategorisierung und Sortierung."
checked={state.functions.includes("filter")}
onChange={() =>
updateState({
functions: toggleItem(state.functions, "filter"),
})
}
/>
<Checkbox
label="PDF-Export"
desc="Automatisierte PDF-Erstellung."
checked={state.functions.includes("pdf")}
onChange={() =>
updateState({
functions: toggleItem(state.functions, "pdf"),
})
}
/>
<Checkbox
label="Erweiterte Formulare"
desc="Komplexe Abfragen & Logik."
checked={state.functions.includes("forms")}
onChange={() =>
updateState({
functions: toggleItem(state.functions, "forms"),
})
}
/>
<Checkbox
label="Mitgliederbereich"
desc="Login-Bereich für exklusive Inhalte."
checked={state.functions.includes("members")}
onChange={() =>
updateState({
functions: toggleItem(state.functions, "members"),
})
}
/>
<Checkbox
label="Event-Kalender"
desc="Verwaltung und Anzeige von Terminen."
checked={state.functions.includes("calendar")}
onChange={() =>
updateState({
functions: toggleItem(state.functions, "calendar"),
})
}
/>
<Checkbox
label="Echtzeit-Chat"
desc="Direkte Kommunikation mit Besuchern."
checked={state.functions.includes("chat")}
onChange={() =>
updateState({
functions: toggleItem(state.functions, "chat"),
})
}
/>
</>
)}
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.2}>
<div className="space-y-12">
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<ListPlus size={16} />
</div>
<h4 className="text-lg font-bold text-slate-900">
Weitere spezifische Wünsche?
</h4>
</div>
<RepeatableList
items={state.otherFunctions}
onAdd={(v) =>
updateState({ otherFunctions: [...state.otherFunctions, v] })
}
onRemove={(i) =>
updateState({
otherFunctions: state.otherFunctions.filter(
(_, idx) => idx !== i,
),
})
}
placeholder={
isWebApp
? "z.B. Echtzeit-Chat, KI-Anbindung, Offline-Modus..."
: "z.B. Mitgliederbereich, Event-Kalender, geschützte Downloads..."
}
/>
</div>
</div>
</Reveal>
</div>
);
}
@@ -1,187 +0,0 @@
"use client";
import * as React from "react";
import { FormState } from "../types";
import { Globe, Info, Plus, X } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { Reveal } from "../../Reveal";
interface LanguageStepProps {
state: FormState;
updateState: (_updates: Partial<FormState>) => void;
}
const COMMON_LANGUAGES = [
{ id: "de", label: "Deutsch" },
{ id: "en", label: "Englisch" },
{ id: "fr", label: "Französisch" },
{ id: "es", label: "Spanisch" },
{ id: "it", label: "Italienisch" },
];
export function LanguageStep({ state, updateState }: LanguageStepProps) {
const basePriceExplanation =
"Jede zusätzliche Sprache erhöht den Gesamtaufwand für Design, Entwicklung und Qualitätssicherung um ca. 20%. Dies deckt die technische Implementierung der Übersetzungsschicht sowie die Anpassung von Layouts für unterschiedliche Textlängen ab.";
const toggleLanguage = (lang: string) => {
const current = state.languagesList || [];
if (current.includes(lang)) {
updateState({ languagesList: current.filter((l) => l !== lang) });
} else {
updateState({ languagesList: [...current, lang] });
}
};
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter((i) => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
const languagesCount = state.languagesList.length || 1;
return (
<div className="space-y-12">
<Reveal width="100%" delay={0.1}>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<Globe size={16} />
</div>
<h4 className="text-lg font-bold text-slate-900">Sprachen</h4>
<span className="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-bold uppercase tracking-wider rounded">
Optional
</span>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow("languages")}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes("languages")
? "bg-slate-900 text-white"
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="space-y-6">
<p className="text-lg text-slate-500 leading-relaxed ml-2">
Welche Sprachen soll Ihre Website unterstützen?
</p>
<div className="flex flex-wrap gap-4">
{COMMON_LANGUAGES.map((lang) => (
<motion.button
key={lang.id}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleLanguage(lang.label)}
className={`px-8 py-4 rounded-2xl font-bold transition-all border-2 ${
state.languagesList.includes(lang.label)
? "bg-slate-900 border-slate-900 text-white"
: "bg-white border-slate-100 text-slate-600 hover:border-slate-300"
}`}
>
{lang.label}
</motion.button>
))}
</div>
<div className="pt-4">
<div className="flex gap-3">
<input
type="text"
placeholder="Weitere Sprache hinzufügen..."
className="flex-1 p-6 bg-white border border-slate-100 rounded-2xl focus:outline-none focus:border-slate-900 transition-colors text-lg"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const val = e.currentTarget.value.trim();
if (val && !state.languagesList.includes(val)) {
updateState({
languagesList: [...state.languagesList, val],
});
e.currentTarget.value = "";
}
}
}}
/>
</div>
</div>
<div className="flex flex-wrap gap-3">
<AnimatePresence>
{state.languagesList
.filter((l) => !COMMON_LANGUAGES.find((cl) => cl.label === l))
.map((lang, i) => (
<motion.div
key={lang}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="flex items-center gap-3 px-6 py-3 bg-slate-100 rounded-full text-base font-bold text-slate-700"
>
<span>{lang}</span>
<button
type="button"
onClick={() =>
updateState({
languagesList: state.languagesList.filter(
(l) => l !== lang,
),
})
}
className="text-slate-400 hover:text-slate-900 transition-colors"
>
<X size={18} />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.3}>
<div className="p-10 bg-slate-900 text-white rounded-2xl space-y-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full -mr-32 -mt-32 blur-3xl" />
<div className="flex items-center gap-4 text-slate-400 relative z-10">
<Info size={16} />
<span className="text-sm font-bold uppercase tracking-widest">
Warum dieser Faktor?
</span>
</div>
<p className="text-lg leading-relaxed text-slate-300 relative z-10 max-w-3xl">
{basePriceExplanation}
</p>
{languagesCount > 1 && (
<div className="pt-8 border-t border-white/10 relative z-10">
<div className="flex justify-between items-center">
<span className="text-lg font-medium text-slate-400">
Aktueller Aufschlagsfaktor:
</span>
<motion.span
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
className="text-4xl font-bold text-white"
>
+{(languagesCount - 1) * 20}%
</motion.span>
</div>
</div>
)}
</div>
</Reveal>
</div>
);
}
@@ -1,183 +0,0 @@
"use client";
import * as React from "react";
import { FormState } from "../types";
import { Checkbox } from "../components/Checkbox";
import {
Link2,
Globe,
Share2,
Instagram,
Linkedin,
Facebook,
Twitter,
Youtube,
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { Reveal } from "../../Reveal";
import { Input } from "../components/Input";
interface PresenceStepProps {
state: FormState;
updateState: (_updates: Partial<FormState>) => void;
toggleItem: (_list: string[], _id: string) => string[];
}
export function PresenceStep({
state,
updateState,
toggleItem,
}: PresenceStepProps) {
const updateUrl = (id: string, url: string) => {
updateState({
socialMediaUrls: {
...state.socialMediaUrls,
[id]: url,
},
});
};
const SOCIAL_PLATFORMS = [
{ id: "instagram", label: "Instagram", icon: Instagram },
{ id: "linkedin", label: "LinkedIn", icon: Linkedin },
{ id: "facebook", label: "Facebook", icon: Facebook },
{ id: "twitter", label: "Twitter / X", icon: Twitter },
{ id: "youtube", label: "YouTube", icon: Youtube },
];
return (
<div className="space-y-6">
<Reveal width="100%" delay={0.1}>
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<Globe size={16} />
</div>
<h4 className="text-lg font-bold text-slate-900">
Bestehende Website
</h4>
<span className="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-bold uppercase tracking-wider rounded">
Optional
</span>
</div>
<Input
label="URL (falls vorhanden)"
type="url"
icon={Link2}
placeholder="https://www.beispiel.de"
value={state.existingWebsite}
onChange={(e) => updateState({ existingWebsite: e.target.value })}
/>
</div>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Reveal width="100%" delay={0.2}>
<Input
label="Bestehende Domain"
placeholder="z.B. beispiel.de"
value={state.existingDomain}
onChange={(e) => updateState({ existingDomain: e.target.value })}
/>
</Reveal>
<Reveal width="100%" delay={0.2}>
<Input
label="Wunsch-Domain"
placeholder="z.B. neue-marke.de"
value={state.wishedDomain}
onChange={(e) => updateState({ wishedDomain: e.target.value })}
/>
</Reveal>
</div>
<Reveal width="100%" delay={0.3}>
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="w-8 h-8 bg-slate-50 rounded-xl flex items-center justify-center text-black">
<Share2 size={16} />
</div>
<h4 className="text-lg font-bold text-slate-900">
Social Media Accounts
</h4>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
{SOCIAL_PLATFORMS.map((platform) => {
const isSelected = state.socialMedia.includes(platform.id);
const Icon = platform.icon;
return (
<motion.button
key={platform.id}
whileHover={{ y: -8, scale: 1.02 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() =>
updateState({
socialMedia: toggleItem(state.socialMedia, platform.id),
})
}
className={`flex flex-col items-center gap-4 p-8 rounded-[2.5rem] border-2 transition-all duration-500 ${
isSelected
? "border-slate-900 bg-slate-900 text-white shadow-2xl shadow-slate-400"
: "border-slate-100 bg-white text-slate-400 hover:border-slate-300 hover:shadow-xl"
}`}
>
<div
className={`p-4 rounded-2xl transition-colors duration-500 ${isSelected ? "bg-white/10 text-white" : "bg-slate-50 text-slate-400"}`}
>
<Icon size={32} />
</div>
<span className="font-bold text-base tracking-tight">
{platform.label}
</span>
</motion.button>
);
})}
</div>
<div className="space-y-4">
<AnimatePresence mode="popLayout">
{state.socialMedia.map((id) => {
const platform = SOCIAL_PLATFORMS.find((p) => p.id === id);
if (!platform) return null;
return (
<motion.div
key={id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
layout
className="relative group"
>
<div className="absolute left-6 top-1/2 -translate-y-1/2 flex items-center gap-3 text-slate-400 group-focus-within:text-slate-900 transition-colors">
<span className="font-bold text-xs uppercase tracking-widest w-20">
{platform.label}
</span>
<div className="w-[1px] h-4 bg-slate-200" />
<Link2 size={18} />
</div>
<input
type="url"
placeholder={`https://${platform.id}.com/ihr-profil`}
value={state.socialMediaUrls[id] || ""}
onChange={(e) => updateUrl(id, e.target.value)}
className="w-full p-6 pl-40 bg-white border border-slate-100 rounded-xl focus:outline-none focus:border-slate-900 transition-all duration-500 text-lg focus:shadow-xl"
/>
</motion.div>
);
})}
</AnimatePresence>
{state.socialMedia.length === 0 && (
<div className="p-12 border-2 border-dashed border-slate-100 rounded-2xl text-center">
<p className="text-slate-400 font-medium">
Wählen Sie oben Ihre Kanäle aus, um die Links hinzuzufügen.
</p>
</div>
)}
</div>
</div>
</Reveal>
</div>
);
}
@@ -1,125 +0,0 @@
"use client";
import * as React from "react";
import { FormState } from "../types";
import { AlertCircle } from "lucide-react";
interface TimelineStepProps {
state: FormState;
updateState: (_updates: Partial<FormState>) => void;
}
export function TimelineStep({ state, updateState }: TimelineStepProps) {
const isMissingAssets =
!state.assets.includes("logo") || !state.assets.includes("content_concept");
const isMissingPages =
state.selectedPages.length === 0 &&
state.otherPages.length === 0 &&
state.otherPagesCount === 0;
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter((i) => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
return (
<div className="space-y-12">
<div className="space-y-6">
<div className="flex justify-between items-center">
<h4 className="text-lg font-bold text-slate-900">Zeitplan</h4>
<button
type="button"
onClick={() => toggleDontKnow("timeline")}
className={`px-4 py-2 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes("timeline")
? "bg-slate-900 text-white"
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
}`}
>
Ich weiß es nicht
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{
id: "asap",
label: "So schnell wie möglich",
desc: "Priorisierter Start gewünscht.",
},
{
id: "2-3-months",
label: "In 2-3 Monaten",
desc: "Normaler Projektvorlauf.",
},
{
id: "3-6-months",
label: "In 3-6 Monaten",
desc: "Langfristige Planung.",
},
{
id: "flexible",
label: "Flexibel",
desc: "Kein fester Termindruck.",
},
].map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => updateState({ deadline: opt.id })}
className={`p-10 rounded-[2.5rem] border-2 text-left transition-all focus:outline-none overflow-hidden relative ${
state.deadline === opt.id
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-100 bg-white hover:border-slate-200"
}`}
>
<h4
className={`text-2xl font-bold mb-2 ${state.deadline === opt.id ? "text-white" : "text-slate-900"}`}
>
{opt.label}
</h4>
<p
className={`text-lg ${state.deadline === opt.id ? "text-slate-200" : "text-slate-500"}`}
>
{opt.desc}
</p>
</button>
))}
</div>
</div>
{state.deadline === "asap" && (
<div className="p-8 bg-slate-50 rounded-xl border border-slate-100 flex gap-6 items-start">
<AlertCircle className="text-slate-900 shrink-0 mt-1" size={28} />
<p className="text-base text-slate-600 leading-relaxed">
<strong>Hinweis:</strong> Bei sehr kurzfristigen Deadlines kann ein
Express-Zuschlag anfallen, um die Kapazitäten entsprechend zu
priorisieren.
</p>
</div>
)}
{(isMissingAssets || isMissingPages) && (
<div className="p-8 bg-amber-50 rounded-xl border border-amber-100 flex gap-6 items-start">
<div className="w-12 h-12 bg-white rounded-xl flex items-center justify-center text-amber-600 shrink-0">
<AlertCircle size={16} />
</div>
<div className="space-y-2">
<p className="text-amber-900 text-xl font-bold">
Mögliche Verzögerungen
</p>
<p className="text-amber-800 text-base leading-relaxed">
Für einen reibungslosen Projektstart benötigen wir noch einige
Details (z.B. {isMissingAssets ? "Logo/Inhaltskonzept" : ""}{" "}
{isMissingAssets && isMissingPages ? "und" : ""}{" "}
{isMissingPages ? "Seitenstruktur" : ""}). Ohne diese kann sich
der Beginn verzögern.
</p>
</div>
</div>
)}
</div>
);
}
@@ -1,83 +0,0 @@
"use client";
import * as React from "react";
import { FormState, ProjectType } from "../types";
import {
ConceptWebsite,
ConceptSystem,
} from "../../Landing/ConceptIllustrations";
import { motion } from "framer-motion";
import { Reveal } from "../../Reveal";
interface TypeStepProps {
state: FormState;
updateState: (_updates: Partial<FormState>) => void;
}
export function TypeStep({ state, updateState }: TypeStepProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
id: "website",
label: "Website",
desc: "Klassische Webpräsenz, Portfolio oder Blog.",
illustration: <ConceptWebsite className="w-12 h-12 mb-3" />,
},
{
id: "web-app",
label: "Web App",
desc: "Internes Tool, Dashboard oder Prozess-Logik.",
illustration: <ConceptSystem className="w-12 h-12 mb-3" />,
},
].map((type, index) => (
<Reveal key={type.id} width="100%" delay={index * 0.1}>
<motion.button
id={`focus-target-${type.id}`}
whileHover={{ y: -8 }}
whileTap={{ scale: 0.98 }}
type="button"
onClick={() => updateState({ projectType: type.id as ProjectType })}
className={`w-full p-8 rounded-2xl border-2 text-left transition-all duration-700 focus:outline-none overflow-hidden relative group ${
state.projectType === type.id
? "border-slate-900 bg-slate-900 text-white shadow-2xl shadow-slate-400"
: "border-slate-100 bg-white hover:border-slate-300 hover:shadow-xl"
}`}
>
<div
className={`transition-all duration-700 group-hover:scale-110 group-hover:rotate-3 ${state.projectType === type.id ? "text-white" : "text-slate-900"}`}
>
{type.illustration}
</div>
<div className="flex items-center gap-3 mb-3">
<h4
className={`text-2xl font-bold tracking-tight ${state.projectType === type.id ? "text-white" : "text-slate-900"}`}
>
{type.label}
</h4>
<span
className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest ${state.projectType === type.id ? "bg-white/20 text-white" : "bg-slate-100 text-slate-500"}`}
>
Grundlage
</span>
</div>
<p
className={`text-base leading-relaxed ${state.projectType === type.id ? "text-slate-200" : "text-slate-500"}`}
>
{type.desc}
</p>
{state.projectType === type.id && (
<motion.div
layoutId="activeType"
className="absolute top-4 right-4 w-5 h-5 bg-white rounded-full shadow-lg flex items-center justify-center"
>
<div className="w-2 h-2 bg-slate-900 rounded-full" />
</motion.div>
)}
</motion.button>
</Reveal>
))}
</div>
);
}
@@ -1,210 +0,0 @@
"use client";
import * as React from "react";
import { FormState } from "../types";
import { Users, Shield, Monitor, Smartphone, Globe, Lock } from "lucide-react";
interface WebAppStepProps {
state: FormState;
updateState: (_updates: Partial<FormState>) => void;
}
export function WebAppStep({ state, updateState }: WebAppStepProps) {
const toggleUserRole = (role: string) => {
const current = state.userRoles || [];
const next = current.includes(role)
? current.filter((r) => r !== role)
: [...current, role];
updateState({ userRoles: next });
};
return (
<div className="space-y-12">
{/* Target Audience */}
<div className="space-y-6">
<div className="flex items-center gap-4">
<h4 className="text-lg font-bold text-slate-900 flex items-center gap-3">
<Users size={16} className="text-black" /> Zielgruppe
</h4>
<span className="px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">
Fokus
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{
id: "internal",
label: "Internes Tool",
desc: "Für Mitarbeiter & Prozesse.",
},
{
id: "external",
label: "Kunden-Portal",
desc: "Für Ihre Endnutzer (B2B/B2C).",
},
].map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => updateState({ targetAudience: opt.id })}
className={`p-8 rounded-xl border-2 text-left transition-all ${
state.targetAudience === opt.id
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-100 bg-white hover:border-slate-200"
}`}
>
<p className="text-xl font-bold">{opt.label}</p>
<p
className={`text-base mt-2 ${state.targetAudience === opt.id ? "text-slate-200" : "text-slate-500"}`}
>
{opt.desc}
</p>
</button>
))}
</div>
</div>
{/* User Roles */}
<div className="space-y-6">
<h4 className="text-lg font-bold text-slate-900">Benutzerrollen</h4>
<p className="text-lg text-slate-500">Wer wird das System nutzen?</p>
<div className="flex flex-wrap gap-4">
{[
"Administratoren",
"Manager",
"Standard-Nutzer",
"Gäste",
"Read-Only",
].map((role) => (
<button
key={role}
type="button"
onClick={() => toggleUserRole(role)}
className={`px-8 py-4 rounded-full border-2 font-bold text-base transition-all ${
(state.userRoles || []).includes(role)
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-100 bg-white hover:border-slate-200"
}`}
>
{role}
</button>
))}
</div>
</div>
{/* Platform Type */}
<div className="space-y-6">
<h4 className="text-lg font-bold text-slate-900 flex items-center gap-3">
<Monitor size={16} className="text-black" /> Plattform-Fokus
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{
id: "desktop",
label: "Desktop First",
icon: <Monitor size={16} />,
},
{
id: "mobile",
label: "Mobile First",
icon: <Smartphone size={16} />,
},
{
id: "pwa",
label: "PWA (Installierbar)",
icon: <Globe size={16} />,
},
].map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => updateState({ platformType: opt.id })}
className={`p-8 rounded-xl border-2 flex flex-col items-center gap-4 transition-all ${
state.platformType === opt.id
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-100 bg-white hover:border-slate-200"
}`}
>
<div
className={
state.platformType === opt.id ? "text-white" : "text-black"
}
>
{opt.icon}
</div>
<span className="font-bold text-lg">{opt.label}</span>
</button>
))}
</div>
</div>
{/* Data Sensitivity */}
<div className="space-y-6">
<h4 className="text-lg font-bold text-slate-900 flex items-center gap-3">
<Shield size={16} className="text-black" /> Datensicherheit
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{
id: "standard",
label: "Standard",
desc: "Normale Nutzerdaten & Profile.",
},
{
id: "high",
label: "Sensibel",
desc: "Finanzdaten, Gesundheitsdaten oder DSGVO-kritisch.",
},
].map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => updateState({ dataSensitivity: opt.id })}
className={`p-8 rounded-xl border-2 text-left transition-all ${
state.dataSensitivity === opt.id
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-100 bg-white hover:border-slate-200"
}`}
>
<p className="text-xl font-bold">{opt.label}</p>
<p
className={`text-base mt-2 ${state.dataSensitivity === opt.id ? "text-slate-200" : "text-slate-500"}`}
>
{opt.desc}
</p>
</button>
))}
</div>
</div>
{/* Authentication */}
<div className="p-10 bg-slate-50 rounded-2xl border border-slate-100 space-y-6">
<h4 className="text-lg font-bold text-slate-900 flex items-center gap-3">
<Lock size={16} className="text-black" /> Authentifizierung
</h4>
<p className="text-lg text-slate-500">
Wie sollen sich Nutzer anmelden?
</p>
<div className="flex flex-wrap gap-4">
{[
"E-Mail / Passwort",
"Social Login",
"SSO / SAML",
"2FA / MFA",
"Magic Links",
].map((method) => (
<div
key={method}
className="px-8 py-4 rounded-full border-2 border-white bg-white font-bold text-base text-slate-400"
>
{method}
</div>
))}
</div>
<p className="text-xs text-slate-400 italic">
Details zur Authentifizierung besprechen wir im Erstgespräch.
</p>
</div>
</div>
);
}
@@ -1,86 +0,0 @@
import * as React from "react";
export type ProjectType = "website" | "web-app" | "ecommerce";
export interface FormState {
projectType: ProjectType;
// Company
companyName: string;
employeeCount: string;
// Existing Presence
existingWebsite: string;
socialMedia: string[];
socialMediaUrls: Record<string, string>;
existingDomain: string;
wishedDomain: string;
// Project
websiteTopic: string;
selectedPages: string[];
otherPages: string[];
otherPagesCount: number;
features: string[];
otherFeatures: string[];
otherFeaturesCount: number;
functions: string[];
otherFunctions: string[];
otherFunctionsCount: number;
apiSystems: string[];
otherTech: string[];
otherTechCount: number;
assets: string[];
otherAssets: string[];
otherAssetsCount: number;
newDatasets: number;
cmsSetup: boolean;
storageExpansion: number;
name: string;
email: string;
phone: string;
role: string;
message: string;
sitemapFile: File | null;
contactFiles: File[];
// Design
designVibe: string;
colorScheme: string[];
references: string[];
designWishes: string;
// Maintenance
expectedAdjustments: string;
languagesList: string[];
// Timeline
deadline: string;
// Web App specific
targetAudience: string;
userRoles: string[];
dataSensitivity: string;
platformType: string;
// Meta
dontKnows: string[];
visualStaging: string;
complexInteractions: string;
}
export interface Totals {
totalPrice: number;
monthlyPrice: number;
totalPagesCount: number;
totalFeatures: number;
totalFunctions: number;
totalApis: number;
languagesCount: number;
}
export interface Step {
id: string;
title: string;
description: string;
illustration: React.ReactNode;
chapter?: string;
}
export interface Chapter {
id: string;
title: string;
steps: string[];
}
@@ -1,8 +0,0 @@
import { calculatePositions as logicCalculatePositions } from "@mintel/pdf";
import { FormState } from "./types";
// @ts-ignore
export type { Position } from "@mintel/pdf";
export const calculatePositions = (state: FormState, pricing: any) =>
logicCalculatePositions(state as any, pricing);
-79
View File
@@ -1,79 +0,0 @@
"use client";
import React from "react";
import { Mermaid } from "./Mermaid";
interface FlowNode {
id: string;
label: string;
style?: string;
}
interface FlowEdge {
from: string;
to: string;
label?: string;
type?: "solid" | "dotted";
}
interface DiagramFlowProps {
direction?: "LR" | "TB" | "RL" | "BT";
nodes?: FlowNode[];
edges?: FlowEdge[];
title?: string;
caption?: string;
id?: string;
showShare?: boolean;
fontSize?: string;
}
export const DiagramFlow: React.FC<DiagramFlowProps> = ({
direction = "LR",
nodes = [],
edges = [],
title,
caption,
id,
showShare = true,
fontSize = "16px",
}) => {
const lines: string[] = [`graph ${direction}`];
// Declare nodes with labels
for (const node of nodes) {
lines.push(` ${node.id}[${JSON.stringify(node.label)}]`);
}
// Add edges
for (const edge of edges) {
const arrow = edge.type === "dotted" ? "-.-" : "-->";
const label = edge.label ? `|${edge.label}|` : "";
lines.push(` ${edge.from} ${arrow} ${label}${edge.to}`);
}
// Add styles
for (const node of nodes) {
if (node.style) {
lines.push(` style ${node.id} ${node.style}`);
}
}
const flowGraph = lines.join("\n");
return (
<div className="my-12">
<Mermaid
graph={flowGraph}
id={id}
title={title}
showShare={showShare}
fontSize={fontSize}
/>
{caption && (
<div className="text-center text-xs text-slate-400 mt-4 italic">
{caption}
</div>
)}
</div>
);
};
-59
View File
@@ -1,59 +0,0 @@
"use client";
import React from "react";
import { Mermaid } from "./Mermaid";
interface GanttTask {
id: string;
name: string;
start: string;
duration: string;
dependencies?: string[];
}
interface DiagramGanttProps {
tasks: GanttTask[];
title?: string;
caption?: string;
id?: string;
showShare?: boolean;
fontSize?: string;
}
export const DiagramGantt: React.FC<DiagramGanttProps> = ({
tasks,
title,
caption,
id,
showShare = true,
fontSize = "16px",
}) => {
const ganttGraph = `gantt
dateFormat YYYY-MM-DD
${(tasks || [])
.map((task) => {
const deps = task.dependencies?.length
? `, after ${task.dependencies.join(" ")}`
: "";
const safeName = task.name.replace(/[:&<>"]/g, " -");
return ` ${safeName} :${task.id}, ${task.start}, ${task.duration}${deps}`;
})
.join("\n")}`;
return (
<div className="my-12">
<Mermaid
graph={ganttGraph}
id={id}
title={title}
showShare={showShare}
fontSize={fontSize}
/>
{caption && (
<div className="text-center text-xs text-slate-400 mt-4 italic">
{caption}
</div>
)}
</div>
);
};
-47
View File
@@ -1,47 +0,0 @@
"use client";
import React from "react";
import { Mermaid } from "./Mermaid";
interface PieSlice {
label: string;
value: number;
}
interface DiagramPieProps {
data: PieSlice[];
title?: string;
caption?: string;
id?: string;
showShare?: boolean;
fontSize?: string;
}
export const DiagramPie: React.FC<DiagramPieProps> = ({
data,
title,
caption,
id,
showShare = true,
fontSize = "16px",
}) => {
const pieGraph = `pie
${(data || []).map((slice) => ` "${slice.label}" : ${slice.value}`).join("\n")}`;
return (
<div className="my-12">
<Mermaid
graph={pieGraph}
id={id}
title={title}
showShare={showShare}
fontSize={fontSize}
/>
{caption && (
<div className="text-center text-xs text-slate-400 mt-4 italic">
{caption}
</div>
)}
</div>
);
};
-105
View File
@@ -1,105 +0,0 @@
"use client";
import React from "react";
import { Mermaid } from "./Mermaid";
interface SequenceParticipant {
id: string;
label?: string;
}
interface SequenceMessage {
from: string;
to: string;
message: string;
type?: "solid" | "dotted" | "async";
}
interface SequenceNote {
over: string | string[];
text: string;
}
type SequenceStep = SequenceMessage | SequenceNote;
function isNote(step: SequenceStep): step is SequenceNote {
return "over" in step;
}
interface DiagramSequenceProps {
participants: (string | SequenceParticipant)[];
steps?: SequenceStep[];
messages?: SequenceMessage[];
children?: React.ReactNode;
title?: string;
caption?: string;
id?: string;
showShare?: boolean;
fontSize?: string;
}
export const DiagramSequence: React.FC<DiagramSequenceProps> = ({
participants,
steps,
messages,
children,
title,
caption,
id,
showShare = true,
fontSize = "16px",
}) => {
const getArrow = (type?: string) => {
switch (type) {
case "dotted":
return "-->>";
case "async":
return "->>";
default:
return "->>";
}
};
const participantLines = (participants || []).map((p) => {
if (typeof p === "string") return ` participant ${p}`;
return p.label
? ` participant ${p.id} as ${p.label}`
: ` participant ${p.id}`;
});
// Support both `steps` (mixed messages + notes) and legacy `messages`
const allSteps = steps || (messages || []);
const stepLines = allSteps.map((step) => {
if (isNote(step)) {
const over = Array.isArray(step.over) ? step.over.join(",") : step.over;
return ` Note over ${over}: ${step.text}`;
}
return ` ${step.from}${getArrow(step.type)}${step.to}: ${step.message}`;
});
const participantLinesSection = participantLines.length > 0 ? `${participantLines.join("\n")}\n` : "";
const generatedStepsSection = stepLines.length > 0 ? stepLines.join("\n") : "";
const sequenceGraph = children
? (typeof children === "string" ? children : "")
: `sequenceDiagram\n${participantLinesSection}${generatedStepsSection}`;
return (
<div className="my-12">
<Mermaid
graph={sequenceGraph}
id={id}
title={title}
showShare={showShare}
fontSize={fontSize}
/>
{caption && (
<div className="text-center text-xs text-slate-400 mt-4 italic">
{caption}
</div>
)}
</div>
);
};
@@ -1,104 +0,0 @@
"use client";
import React, { useState } from "react";
import { Share2 } from "lucide-react";
import { ShareModal } from "./ShareModal";
import { useAnalytics } from "./analytics/useAnalytics";
interface DiagramShareButtonProps {
diagramId: string;
title?: string;
svgContent?: string;
}
export const DiagramShareButton: React.FC<DiagramShareButtonProps> = ({
diagramId,
title = "Diagram",
svgContent,
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const { trackEvent } = useAnalytics();
const currentUrl =
typeof window !== "undefined"
? `${window.location.origin}${window.location.pathname}#${diagramId}`
: "";
// Convert SVG to PNG for sharing
const generateDiagramImage = async (): Promise<string | undefined> => {
if (!svgContent) return undefined;
try {
// Create a canvas to render the SVG
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return undefined;
// Create an image from the SVG
const img = new Image();
const svgBlob = new Blob([svgContent], {
type: "image/svg+xml;charset=utf-8",
});
const url = URL.createObjectURL(svgBlob);
return new Promise((resolve) => {
img.onload = () => {
// Set canvas size to match image
canvas.width = img.width * 2; // 2x for better quality
canvas.height = img.height * 2;
// Fill with white background
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw the image
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Convert to data URL
const dataUrl = canvas.toDataURL("image/png");
URL.revokeObjectURL(url);
resolve(dataUrl);
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(undefined);
};
img.src = url;
});
} catch (err) {
console.error("Failed to generate diagram image:", err);
return undefined;
}
};
const handleOpenModal = async () => {
setIsModalOpen(true);
trackEvent("diagram_share_opened", {
diagram_id: diagramId,
diagram_title: title,
});
};
return (
<>
<button
onClick={handleOpenModal}
className="inline-flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 rounded-lg text-slate-700 hover:bg-slate-50 hover:border-slate-300 transition-all text-xs font-medium"
aria-label="Diagramm teilen"
>
<Share2 className="w-3.5 h-3.5" />
<span>Teilen</span>
</button>
<ShareModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
url={currentUrl}
title={title}
diagramImage={svgContent}
/>
</>
);
};
-61
View File
@@ -1,61 +0,0 @@
"use client";
import React from "react";
import { Mermaid } from "./Mermaid";
interface StateTransition {
from: string;
to: string;
label?: string;
}
interface DiagramStateProps {
states: string[];
transitions: StateTransition[];
initialState?: string;
finalStates?: string[];
title?: string;
caption?: string;
id?: string;
showShare?: boolean;
fontSize?: string;
}
export const DiagramState: React.FC<DiagramStateProps> = ({
states,
transitions,
initialState,
finalStates = [],
title,
caption,
id,
showShare = true,
fontSize = "16px",
}) => {
const stateGraph = `stateDiagram-v2
${initialState ? ` [*] --> ${initialState}` : ""}
${(transitions || [])
.map((t) => {
const label = t.label ? ` : ${t.label}` : "";
return ` ${t.from} --> ${t.to}${label}`;
})
.join("\n")}
${(finalStates || []).map((s) => ` ${s} --> [*]`).join("\n")}`;
return (
<div className="my-12">
<Mermaid
graph={stateGraph}
id={id}
title={title}
showShare={showShare}
fontSize={fontSize}
/>
{caption && (
<div className="text-center text-xs text-slate-400 mt-4 italic">
{caption}
</div>
)}
</div>
);
};
@@ -1,48 +0,0 @@
"use client";
import React from "react";
import { Mermaid } from "./Mermaid";
interface TimelineEvent {
year: string;
title: string;
}
interface DiagramTimelineProps {
events: TimelineEvent[];
title?: string;
caption?: string;
id?: string;
showShare?: boolean;
fontSize?: string;
}
export const DiagramTimeline: React.FC<DiagramTimelineProps> = ({
events,
title,
caption,
id,
showShare = true,
fontSize = "16px",
}) => {
const timelineGraph = `timeline
title ${title || "Timeline"}
${(events || []).map((event) => ` ${event.year} : ${event.title}`).join("\n")}`;
return (
<div className="my-12">
<Mermaid
graph={timelineGraph}
id={id}
title={title}
showShare={showShare}
fontSize={fontSize}
/>
{caption && (
<div className="text-center text-xs text-slate-400 mt-4 italic">
{caption}
</div>
)}
</div>
);
};
+33
View File
@@ -0,0 +1,33 @@
"use client";
import * as React from "react";
import { CONTACT_EMAIL_PARTS } from "@/src/content/site";
import { assembleEmail, spellOutEmail } from "@/src/domain/email";
/**
* EmailLink: shows the contact address without putting it into the page source.
* The server renders a spelled-out form ("name [at] domain [dot] tld"). The real
* address and the mailto link are only assembled in the browser. This keeps simple
* address harvesters out; it does not stop a bot that runs JavaScript.
*/
export const EmailLink: React.FC<{ className?: string }> = ({
className = "",
}) => {
const [address, setAddress] = React.useState<string | null>(null);
React.useEffect(() => {
setAddress(assembleEmail(CONTACT_EMAIL_PARTS));
}, []);
if (address === null) {
return (
<span className={className}>{spellOutEmail(CONTACT_EMAIL_PARTS)}</span>
);
}
return (
<a href={`mailto:${address}`} className={className}>
{address}
</a>
);
};
-43
View File
@@ -1,43 +0,0 @@
// Embed Components Index
// Re-export for convenience
export { YouTubeEmbed } from "../YouTubeEmbed";
export { TwitterEmbed } from "../TwitterEmbed";
export { GenericEmbed } from "../GenericEmbed";
export { Mermaid } from "../Mermaid";
export { DiagramTimeline } from "../DiagramTimeline";
export { DiagramSequence } from "../DiagramSequence";
export { DiagramPie } from "../DiagramPie";
export { DiagramGantt } from "../DiagramGantt";
export { DiagramState } from "../DiagramState";
export { DiagramShareButton } from "../DiagramShareButton";
// Type definitions for props
export interface MermaidProps {
graph: string;
id?: string;
title?: string;
showShare?: boolean;
}
export interface YouTubeEmbedProps {
videoId: string;
title?: string;
className?: string;
aspectRatio?: string;
style?: "default" | "minimal" | "rounded" | "flat";
}
export interface TwitterEmbedProps {
tweetId: string;
theme?: "light" | "dark";
className?: string;
align?: "left" | "center" | "right";
}
export interface GenericEmbedProps {
url: string;
className?: string;
maxWidth?: string;
type?: "video" | "article" | "rich";
}
-45
View File
@@ -1,45 +0,0 @@
'use client';
import React from 'react';
import { track } from '../utils/analytics';
interface ExternalLinkProps {
href: string;
children: React.ReactNode;
className?: string;
}
export const ExternalLink: React.FC<ExternalLinkProps> = ({ href, children, className = '' }) => {
const handleClick = () => {
const text = typeof children === 'string' ? children : href;
track('Outbound Link', { url: href, text });
};
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={handleClick}
className={`inline-flex items-center gap-0.5 text-slate-600 hover:text-slate-900 underline underline-offset-2 decoration-slate-300 hover:decoration-slate-500 transition-colors whitespace-nowrap ${className}`}
>
<span className="whitespace-normal">{children}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="inline-block ml-0.5 opacity-40 flex-shrink-0 align-baseline"
style={{ transform: 'translateY(1px)' }}
aria-hidden="true"
>
<path d="M7 17L17 7" />
<path d="M7 7h10v10" />
</svg>
</a>
);
};
-29
View File
@@ -1,29 +0,0 @@
"use client";
import React from "react";
import { H3 } from "./ArticleHeading";
import { Paragraph } from "./ArticleParagraph";
interface FAQItem {
question: string;
answer: string;
}
interface FAQSectionProps {
children?: React.ReactNode;
}
/**
* FAQSection: A simple semantic wrapper for FAQs in blog posts.
* It can be used by the AI to wrap a list of questions and answers.
*/
export const FAQSection: React.FC<FAQSectionProps> = ({ children }) => {
return (
<div className="my-16 border-t border-slate-100 pt-12">
<H3 id="faq">Häufig gestellte Fragen (FAQ)</H3>
<div className="mt-8 space-y-8">
{children}
</div>
</div>
);
};
-180
View File
@@ -1,180 +0,0 @@
"use client";
import React, { useState, useRef } from "react";
import * as Prism from "prismjs";
import "prismjs/components/prism-python";
import "prismjs/components/prism-typescript";
import "prismjs/components/prism-javascript";
import "prismjs/components/prism-jsx";
import "prismjs/components/prism-tsx";
import "prismjs/components/prism-docker";
import "prismjs/components/prism-yaml";
import "prismjs/components/prism-json";
import "prismjs/components/prism-markup";
import "prismjs/components/prism-css";
import "prismjs/components/prism-sql";
import "prismjs/components/prism-bash";
import "prismjs/components/prism-markdown";
import { cn } from "../utils/cn";
import { CodeWindow } from "./Effects/CodeWindow";
interface FileExampleProps {
filename: string;
content: string;
language: string;
description?: string;
tags?: string[];
id: string;
}
const prismLanguageMap: Record<string, string> = {
py: "python",
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
dockerfile: "docker",
docker: "docker",
yml: "yaml",
yaml: "yaml",
json: "json",
html: "markup",
css: "css",
sql: "sql",
sh: "bash",
bash: "bash",
md: "markdown",
};
export const FileExample: React.FC<FileExampleProps> = ({
filename,
content,
language,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const fileExtension = filename.split(".").pop() || language;
const prismLanguage = prismLanguageMap[fileExtension] || "markup";
const highlightedCode = Prism.highlight(
content,
Prism.languages[prismLanguage] || Prism.languages.markup,
prismLanguage,
);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 900);
} catch (err) {
console.error("Failed to copy:", err);
}
};
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const actions = (
<>
<button
type="button"
className={`h-7 w-7 inline-flex items-center justify-center rounded-full border border-slate-200 bg-white text-slate-400 hover:text-slate-900 hover:border-slate-400 transition-all ${isCopied ? "text-green-500 border-green-200" : ""}`}
onClick={handleCopy}
title="Copy to clipboard"
>
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
<button
type="button"
className="h-7 w-7 inline-flex items-center justify-center rounded-full border border-slate-200 bg-white text-slate-400 hover:text-slate-900 hover:border-slate-400 transition-all"
onClick={handleDownload}
title="Download file"
>
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
</>
);
return (
<div className="my-8 group/file">
<CodeWindow
title={filename}
actions={actions}
className={cn(
"transition-all duration-300",
isExpanded
? "max-w-4xl"
: "max-w-[600px] cursor-pointer hover:border-slate-200",
)}
minHeight={isExpanded ? "auto" : "80px"}
>
<div
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
"relative",
!isExpanded && "max-h-[80px] overflow-hidden",
)}
>
<pre
className="m-0 p-0 overflow-x-auto text-[13px] leading-[1.65] font-mono text-slate-800"
style={{
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
}}
>
<code
className={`language-${prismLanguage}`}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
></code>
</pre>
{!isExpanded && (
<div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent flex items-end justify-center pb-2">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
Click to expand
</span>
</div>
)}
</div>
</CodeWindow>
</div>
);
};
+55 -59
View File
@@ -1,71 +1,67 @@
import Image from "next/image";
import Link from "next/link";
import * as React from "react";
import { AVAILABILITY_CONFIG } from "../content/site";
import {
formatAvailability,
resolveAvailability,
} from "../domain/availability";
const NAV_LINKS = [
{ href: "/notes", label: "Notes" },
{ href: "/process", label: "Process" },
{ href: "/about", label: "About" },
{ href: "/book", label: "Book a call" },
] as const;
const LEGAL_LINKS = [
{ href: "/legal/impressum", label: "Imprint" },
{ href: "/legal/datenschutz", label: "Privacy" },
{ href: "/legal/agb", label: "Terms" },
] as const;
const LINK_CLASS = "text-muted transition-colors hover:text-fg";
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
export const Footer: React.FC = () => {
const now = new Date();
const today = now.toISOString().slice(0, 10);
const availability = formatAvailability(
resolveAvailability(AVAILABILITY_CONFIG, today),
);
const currentYear = new Date().getFullYear();
return (
<footer className="relative z-10 mt-16 border-t border-border bg-bg py-10 md:mt-24 md:py-14">
<div className="mx-auto w-full max-w-6xl px-5 md:px-8">
<p className="!mb-8 flex items-center gap-2 font-mono text-sm text-fg">
<span aria-hidden="true" className="h-2 w-2 rounded-full bg-accent" />
{availability}
</p>
<div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
<div className="space-y-4">
<p className="!mb-0 font-mono text-lg font-semibold text-fg">
mintel<span className="text-accent">.</span>me
</p>
<nav
aria-label="Footer"
className="flex flex-wrap gap-x-6 gap-y-2 text-sm"
>
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href} className={LINK_CLASS}>
{link.label}
</Link>
))}
</nav>
<footer className="relative py-10 md:py-16 mt-8 md:mt-24 border-t border-slate-100 bg-white z-10">
{/* Tech border at top */}
<div className="absolute top-0 left-0 right-0 h-px overflow-hidden">
<div
className="h-full w-full"
style={{
background:
"linear-gradient(90deg, transparent 0%, rgba(148, 163, 184, 0.15) 25%, rgba(191, 206, 228, 0.1) 50%, rgba(148, 163, 184, 0.15) 75%, transparent 100%)",
}}
/>
</div>
<div className="mx-auto w-full max-w-[1400px] px-5 md:px-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 items-end">
<div className="space-y-6">
<div className="flex items-center gap-3">
<Image
src={LogoBlack}
alt="Marc Mintel"
height={64}
className="h-12 md:h-16 w-auto"
/>
</div>
</div>
<div className="space-y-4 text-sm md:text-right">
<nav
aria-label="Legal"
className="flex flex-wrap gap-x-6 gap-y-2 md:justify-end"
<div className="flex flex-col md:items-end gap-3 md:gap-4 text-xs md:text-sm font-mono text-slate-300 uppercase tracking-widest">
<span>© {currentYear}</span>
<div className="flex flex-wrap gap-x-8 gap-y-3 md:justify-end">
<Link
href="/#contact"
className="hover:text-slate-900 transition-colors duration-300 no-underline"
>
Get in touch
</Link>
<Link
href="/imprint"
className="hover:text-slate-900 transition-colors duration-300 no-underline"
>
Imprint
</Link>
<Link
href="/privacy"
className="hover:text-slate-900 transition-colors duration-300 no-underline"
>
Privacy
</Link>
</div>
{/* Subtle binary decoration */}
<span
className="text-[8px] text-slate-200 tracking-[0.5em] select-none font-mono"
aria-hidden="true"
>
{LEGAL_LINKS.map((link) => (
<Link key={link.href} href={link.href} className={LINK_CLASS}>
{link.label}
</Link>
))}
</nav>
<p className="!mb-0 font-mono text-xs text-faint">
© {now.getFullYear()} Marc Mintel
</p>
01001101 01001001 01001110
</span>
</div>
</div>
</div>
-98
View File
@@ -1,98 +0,0 @@
import React from 'react';
interface GenericEmbedProps {
url: string;
className?: string;
maxWidth?: string;
type?: 'video' | 'article' | 'rich';
}
export const GenericEmbed: React.FC<GenericEmbedProps> = ({
url,
className = "",
maxWidth = "100%",
type = 'rich'
}) => {
let embedUrl: string | null = null;
let provider = 'unknown';
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname.replace('www.', '');
if (hostname.includes('youtube.com') || hostname.includes('youtu.be')) {
const videoId = urlObj.searchParams.get('v') || urlObj.pathname.split('/').pop();
if (videoId) {
embedUrl = `https://www.youtube.com/embed/${videoId}`;
provider = 'youtube.com';
}
}
else if (hostname.includes('vimeo.com')) {
const videoId = urlObj.pathname.split('/').filter(Boolean)[0];
if (videoId) {
embedUrl = `https://player.vimeo.com/video/${videoId}`;
provider = 'vimeo.com';
}
}
else if (hostname.includes('codepen.io')) {
const penPath = urlObj.pathname.replace('/pen/', '/');
embedUrl = `https://codepen.io${penPath}?default-tab=html,result`;
provider = 'codepen.io';
}
else if (hostname.includes('gist.github.com')) {
const gistPath = urlObj.pathname;
embedUrl = `https://gist.github.com${gistPath}.js`;
provider = 'gist.github.com';
}
} catch (e) {
console.warn('GenericEmbed: Failed to parse URL', e);
}
const hasEmbed = embedUrl !== null;
return (
<div
className={`generic-embed not-prose ${className}`}
data-provider={provider}
data-type={type}
style={{ '--max-width': maxWidth } as React.CSSProperties}
>
{hasEmbed ? (
<div className="embed-wrapper">
{type === 'video' ? (
<iframe
src={embedUrl!}
width="100%"
height="100%"
style={{ border: 'none', width: '100%', height: '100%' }}
allowFullScreen
loading="lazy"
title={`${provider} embed`}
/>
) : (
<iframe
src={embedUrl!}
width="100%"
height="400"
style={{ border: 'none' }}
loading="lazy"
title={`${provider} embed`}
/>
)}
</div>
) : (
<div className="embed-fallback">
<div className="fallback-content">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Unable to embed this URL</span>
<a href={url} target="_blank" rel="noopener noreferrer" className="fallback-link">
Open link →
</a>
</div>
</div>
)}
</div>
);
};
-124
View File
@@ -1,124 +0,0 @@
"use client";
import * as React from "react";
import { useInView } from "framer-motion";
/**
* GlitchText: Binary scramble reveal effect
*
* Text characters scramble through random binary (0/1) before settling
* into the final text. Creates a "decoding" feel.
*
* Can also be triggered on hover for buttons.
*/
interface GlitchTextProps {
children: string;
className?: string;
delay?: number;
duration?: number;
trigger?: "inView" | "hover" | "mount";
as?: "span" | "div" | "h1" | "h2" | "p";
}
const CHARS = "01";
export const GlitchText: React.FC<GlitchTextProps> = ({
children,
className = "",
delay = 0,
duration = 0.8,
trigger = "inView",
as: Tag = "span",
}) => {
const text = children;
const [display, setDisplay] = React.useState(text);
const [isRevealed, setIsRevealed] = React.useState(false);
const [isHovering, setIsHovering] = React.useState(false);
const ref = React.useRef<any>(null);
const isInView = useInView(ref, { once: true, margin: "-10%" });
// Initial scramble reveal (on mount or in-view)
React.useEffect(() => {
if (isRevealed) return;
const shouldStart =
trigger === "mount" || (trigger === "inView" && isInView);
if (!shouldStart) return;
const chars = text.split("");
const totalFrames = Math.ceil(duration * 60); // ~60fps
const staggerPerChar = totalFrames / chars.length;
let frame = 0;
const delayMs = delay * 1000;
const timeout = setTimeout(() => {
const interval = setInterval(() => {
frame++;
const revealed = chars.map((char, i) => {
if (char === " ") return " ";
const charFrame = i * staggerPerChar;
if (frame > charFrame + staggerPerChar * 2) return char; // Settled
if (frame > charFrame) {
// Scrambling phase
return CHARS[Math.floor(Math.random() * CHARS.length)];
}
return CHARS[Math.floor(Math.random() * CHARS.length)];
});
setDisplay(revealed.join(""));
if (frame >= totalFrames + staggerPerChar * 2) {
setDisplay(text);
setIsRevealed(true);
clearInterval(interval);
}
}, 1000 / 60);
return () => clearInterval(interval);
}, delayMs);
return () => clearTimeout(timeout);
}, [isInView, trigger, text, delay, duration, isRevealed]);
// Hover re-scramble (for buttons)
React.useEffect(() => {
if (!isHovering || trigger !== "hover") return;
const chars = text.split("");
const totalFrames = 20; // Quick scramble
let frame = 0;
const interval = setInterval(() => {
frame++;
const scrambled = chars.map((char, i) => {
if (char === " ") return " ";
const settle = (frame / totalFrames) * chars.length;
if (i < settle) return char;
return CHARS[Math.floor(Math.random() * CHARS.length)];
});
setDisplay(scrambled.join(""));
if (frame >= totalFrames) {
setDisplay(text);
clearInterval(interval);
}
}, 1000 / 60);
return () => clearInterval(interval);
}, [isHovering, trigger, text]);
return (
<Tag
ref={ref}
className={`${className}`}
onMouseEnter={trigger === "hover" ? () => setIsHovering(true) : undefined}
onMouseLeave={
trigger === "hover" ? () => setIsHovering(false) : undefined
}
style={{ fontVariantNumeric: "tabular-nums" }} // Prevent layout shift during scramble
>
{display}
</Tag>
);
};
+47 -155
View File
@@ -1,175 +1,67 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { useSafePathname } from "./analytics/useSafePathname";
import * as React from "react";
import { ButtonLink } from "./site/ButtonLink";
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
/** Header: the logo and one action. Everything else lives on the homepage. */
export const Header: React.FC = () => {
const pathname = useSafePathname();
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
// Close mobile menu on pathname change and handle body scroll lock
React.useEffect(() => {
setIsMobileMenuOpen(false);
}, [pathname]);
const [isScrolled, setIsScrolled] = React.useState(false);
React.useEffect(() => {
if (isMobileMenuOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
};
}, [isMobileMenuOpen]);
const navLinks = [
{ href: "/notes", label: "Notes" },
{ href: "/process", label: "Process" },
{ href: "/about", label: "About" },
] as const;
const linkActive = (href: string) =>
pathname === href || Boolean(pathname?.startsWith(`${href}/`));
const closeMenu = () => setIsMobileMenuOpen(false);
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<header className="sticky top-0 z-[100] w-full border-b border-border bg-bg">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between px-5 py-4 md:px-8">
<header className="sticky top-0 z-[100] w-full">
{/* Decoupled Background Layer - Prevents backdrop-filter parent context bugs */}
<div
className={`absolute inset-0 transition-all duration-500 -z-10 ${
isScrolled
? "bg-white/70 backdrop-blur-xl border-b border-slate-100 shadow-sm shadow-slate-100/50"
: "bg-white/80 backdrop-blur-md border-b border-slate-50"
}`}
/>
<div className="mx-auto w-full max-w-[1400px] px-5 md:px-12 py-4 flex items-center justify-between relative z-10">
<Link
href="/"
aria-label="mintel.me home"
className="font-mono text-lg font-semibold text-fg"
aria-label="Marc Mintel, back to top"
onClick={(event) => {
// Already on the homepage: scroll up instead of reloading the route.
if (window.location.pathname === "/") {
event.preventDefault();
window.scrollTo({ top: 0, behavior: "smooth" });
}
}}
className="flex items-center gap-4 group"
>
mintel<span className="text-accent">.</span>me
</Link>
{/* Desktop Navigation */}
<nav aria-label="Main" className="hidden items-center gap-8 md:flex">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
aria-current={linkActive(link.href) ? "page" : undefined}
className={`text-sm transition-colors ${
linkActive(link.href) ? "text-fg" : "text-muted hover:text-fg"
}`}
>
{link.label}
</Link>
))}
<ButtonLink href="/book">Book a call</ButtonLink>
</nav>
{/* Mobile Toggle */}
<button
className="relative z-[110] flex h-10 w-10 items-center justify-center rounded-md border border-border-strong text-fg md:hidden"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Toggle Menu"
aria-expanded={isMobileMenuOpen}
>
<div className="relative flex h-3.5 w-5 flex-col justify-between">
<motion.span
animate={
isMobileMenuOpen ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }
}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="h-0.5 w-full origin-center rounded-full bg-current"
/>
<motion.span
animate={
isMobileMenuOpen ? { opacity: 0, x: -10 } : { opacity: 1, x: 0 }
}
transition={{ duration: 0.2 }}
className="h-0.5 w-full rounded-full bg-current"
/>
<motion.span
animate={
isMobileMenuOpen ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }
}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="h-0.5 w-full origin-center rounded-full bg-current"
<div className="w-10 h-10 md:w-12 md:h-12 bg-black rounded-xl flex items-center justify-center group-hover:scale-105 transition-all duration-500 shadow-sm shrink-0 relative overflow-hidden">
<Image
src={IconWhite}
alt=""
width={32}
height={32}
className="w-6 h-6 md:w-8 md:h-8 relative z-10"
/>
</div>
</button>
</Link>
<Link
href="/#contact"
className="font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
style={{
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
Get in touch
</Link>
</div>
{/* Mobile Navigation - bottom sheet */}
<AnimatePresence>
{isMobileMenuOpen && (
<React.Fragment key="mobile-menu">
<motion.div
key="menu-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
onClick={closeMenu}
className="fixed inset-0 z-[101] bg-black/60 md:hidden"
/>
<motion.div
key="menu-sheet"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{
type: "spring",
stiffness: 350,
damping: 30,
mass: 0.8,
}}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={0.15}
onDragEnd={(_, info) => {
if (info.offset.y > 80 || info.velocity.y > 300) {
closeMenu();
}
}}
className="fixed inset-x-0 bottom-0 z-[102] flex max-h-[85vh] flex-col overflow-hidden rounded-t-xl border-t border-border-strong bg-surface md:hidden"
>
<div className="flex cursor-grab justify-center pb-2 pt-3 active:cursor-grabbing">
<div className="h-1 w-10 rounded-full bg-border-strong" />
</div>
<nav
aria-label="Mobile"
className="flex-1 overflow-y-auto px-5 pb-3 pt-2"
>
<ul className="m-0 list-none p-0">
{navLinks.map((item) => (
<li key={item.href} className="!mb-0">
<Link
href={item.href}
onClick={closeMenu}
aria-current={
linkActive(item.href) ? "page" : undefined
}
className={`flex items-center justify-between border-b border-border py-4 text-lg ${
linkActive(item.href) ? "text-accent" : "text-fg"
}`}
>
{item.label}
</Link>
</li>
))}
</ul>
</nav>
<div className="px-5 pb-5 pt-2">
<ButtonLink href="/book" className="w-full">
Book a call
</ButtonLink>
</div>
<div className="h-[env(safe-area-inset-bottom,0px)]" />
</motion.div>
</React.Fragment>
)}
</AnimatePresence>
</header>
);
};
-315
View File
@@ -1,315 +0,0 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import * as React from "react";
import IconWhite from "../assets/logo/Icon White Transparent.svg";
export const Header: React.FC = () => {
const pathname = usePathname();
const [isScrolled, setIsScrolled] = React.useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
React.useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
// Close mobile menu on pathname change and handle body scroll lock
React.useEffect(() => {
setIsMobileMenuOpen(false);
}, [pathname]);
React.useEffect(() => {
if (isMobileMenuOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [isMobileMenuOpen]);
const isActive = (path: string) => pathname === path;
const navLinks = [
{ href: "/about", label: "Über mich" },
{ href: "/websites", label: "Websites" },
{ href: "/case-studies", label: "Case Studies", prefix: true },
{ href: "/blog", label: "Blog", prefix: true },
];
return (
<header className="sticky top-0 z-[100] w-full">
{/* Decoupled Background Layer - Prevents backdrop-filter parent context bugs */}
<div
className={`absolute inset-0 transition-all duration-500 -z-10 ${
isScrolled
? "bg-white/70 backdrop-blur-xl border-b border-slate-100 shadow-sm shadow-slate-100/50"
: "bg-white/80 backdrop-blur-md border-b border-slate-50"
}`}
/>
{/* Animated tech border at bottom */}
<div className="absolute bottom-0 left-0 right-0 h-px overflow-hidden pointer-events-none">
<div
className="h-full w-full"
style={{
background: isScrolled
? "linear-gradient(90deg, transparent 0%, rgba(148, 163, 184, 0.15) 30%, rgba(191, 206, 228, 0.1) 50%, rgba(148, 163, 184, 0.15) 70%, transparent 100%)"
: "transparent",
transition: "background 0.5s ease",
}}
/>
</div>
<div className="narrow-container py-4 flex items-center justify-between relative z-10">
<Link href="/" className="flex items-center gap-4 group">
<div className="flex items-center gap-3">
<div className="w-10 h-10 md:w-12 md:h-12 bg-black rounded-xl flex items-center justify-center group-hover:scale-105 transition-all duration-500 shadow-sm shrink-0 relative overflow-hidden">
<Image
src={IconWhite}
alt="Marc Mintel Icon"
width={32}
height={32}
className="w-6 h-6 md:w-8 md:h-8 relative z-10"
/>
</div>
</div>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-8">
{navLinks.map((link) => {
const active = link.prefix
? isActive(link.href) || pathname?.startsWith(`${link.href}/`)
: isActive(link.href);
return (
<Link
key={link.href}
href={link.href}
className={`text-xs font-bold uppercase tracking-widest transition-colors duration-300 relative ${
active
? "text-slate-900"
: "text-slate-400 hover:text-slate-900"
}`}
>
{active && (
<span className="absolute -bottom-1 left-0 right-0 flex justify-center">
<span className="w-1 h-1 rounded-full bg-slate-900 animate-circuit-pulse" />
</span>
)}
{link.label}
</Link>
);
})}
<Link
href="/contact"
className="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
style={{
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
Anfrage
</Link>
</nav>
{/* Mobile Toggle */}
<button
className="md:hidden relative z-[110] p-2 w-10 h-10 flex items-center justify-center rounded-xl bg-slate-900 text-white active:scale-90 transition-all duration-300 shadow-lg shadow-slate-200"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Toggle Menu"
>
<div className="w-5 h-3.5 relative flex flex-col justify-between">
<motion.span
animate={
isMobileMenuOpen ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }
}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="w-full h-0.5 bg-current rounded-full origin-center"
/>
<motion.span
animate={
isMobileMenuOpen ? { opacity: 0, x: -10 } : { opacity: 1, x: 0 }
}
transition={{ duration: 0.2 }}
className="w-full h-0.5 bg-current rounded-full"
/>
<motion.span
animate={
isMobileMenuOpen ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }
}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="w-full h-0.5 bg-current rounded-full origin-center"
/>
</div>
</button>
</div>
{/* Mobile Navigation - Bottom-Anchored Control Center */}
<AnimatePresence>
{isMobileMenuOpen && (
<React.Fragment key="mobile-control-center">
{/* Dimmed Backdrop */}
<motion.div
key="cc-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
onClick={() => setIsMobileMenuOpen(false)}
className="fixed inset-0 z-[101] bg-black/30 backdrop-blur-sm md:hidden"
/>
{/* Bottom Sheet */}
<motion.div
key="cc-sheet"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{
type: "spring",
stiffness: 350,
damping: 30,
mass: 0.8,
}}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={0.15}
onDragEnd={(_, info) => {
if (info.offset.y > 80 || info.velocity.y > 300) {
setIsMobileMenuOpen(false);
}
}}
className="fixed inset-x-0 bottom-0 z-[102] md:hidden bg-white rounded-t-[2rem] shadow-[0_-8px_40px_rgba(0,0,0,0.12)] flex flex-col max-h-[85vh] overflow-hidden"
>
{/* Grab Handle */}
<div className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing">
<div className="w-10 h-1 bg-slate-200 rounded-full" />
</div>
{/* Status Bar */}
<div className="px-6 py-3 flex justify-between items-center border-b border-slate-100/80">
<div className="flex items-center gap-2 text-[9px] font-mono font-bold tracking-[0.15em] text-slate-400 uppercase">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
Online
</div>
<div className="text-[9px] font-mono font-bold tracking-widest text-slate-400 uppercase">
{pathname === "/"
? "HOME"
: pathname.toUpperCase().replace(/^\//, "")}
</div>
</div>
{/* Tiled Navigation Grid */}
<div className="px-5 pt-5 pb-3 flex-1 overflow-y-auto">
<div className="grid grid-cols-2 gap-2.5">
{[
{ href: "/about", label: "Über mich", sub: "Architect" },
{ href: "/websites", label: "Websites", sub: "Systems" },
{
href: "/case-studies",
label: "Cases",
sub: "Solutions",
prefix: true,
},
{
href: "/blog",
label: "Blog",
sub: "Insights",
prefix: true,
},
].map((item, i) => {
const active = item.prefix
? pathname?.startsWith(item.href)
: pathname === item.href;
return (
<motion.div
key={item.href}
initial={{ opacity: 0, scale: 0.85, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{
delay: 0.05 + i * 0.04,
type: "spring",
stiffness: 400,
damping: 25,
}}
whileTap={{ scale: 0.95 }}
>
<Link
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={`relative flex flex-col justify-center p-6 h-[110px] rounded-2xl border transition-all duration-200 ${
active
? "bg-slate-50 border-slate-200 ring-1 ring-slate-200"
: "bg-white border-slate-100 active:bg-slate-50"
}`}
>
<div>
<span className="text-[15px] font-black tracking-tight text-slate-900 block leading-tight mb-1">
{item.label}
</span>
<span className="text-[9px] font-mono font-bold text-slate-400 uppercase tracking-[0.2em]">
{item.sub}
</span>
</div>
{active && (
<div className="absolute top-4 right-4 w-1.5 h-1.5 rounded-full bg-slate-900" />
)}
</Link>
</motion.div>
);
})}
</div>
</div>
{/* Primary CTA */}
<div className="px-5 pb-5 pt-2">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25, type: "spring", stiffness: 300 }}
whileTap={{ scale: 0.97 }}
>
<Link
href="/contact"
onClick={() => setIsMobileMenuOpen(false)}
className="flex items-center justify-between w-full p-4 bg-slate-900 text-white rounded-2xl active:bg-slate-800 transition-colors"
>
<span className="text-[13px] font-bold uppercase tracking-[0.15em]">
Projekt anfragen
</span>
<svg
className="w-4 h-4 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M14 5l7 7m0 0l-7 7m7-7H3"
/>
</svg>
</Link>
</motion.div>
</div>
{/* Safe-Area Spacer (iOS home indicator) */}
<div className="h-[env(safe-area-inset-bottom,0px)]" />
</motion.div>
</React.Fragment>
)}
</AnimatePresence>
</header>
);
};
-69
View File
@@ -1,69 +0,0 @@
import { BookOpen, Code2, Terminal, Wrench } from "lucide-react";
import * as React from "react";
export const Hero: React.FC = () => {
return (
<section className="relative bg-slate-900 text-white py-16 md:py-24 overflow-hidden text-center">
{/* Background pattern */}
<div className="absolute inset-0 opacity-10">
<div
className="absolute inset-0"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, white 1px, transparent 0)",
backgroundSize: "32px 32px",
}}
/>
</div>
<div className="container relative z-10">
<div className="max-w-4xl mx-auto">
{/* Main heading */}
<h1 className="text-4xl md:text-6xl font-bold mb-4 md:mb-6 animate-slide-up leading-tight">
Digital Problem Solver
</h1>
<p className="text-lg md:text-2xl text-slate-300 mb-8 leading-relaxed animate-fade-in px-4 md:px-0">
I work on Digital problems and build tools, scripts, and systems to
solve them.
</p>
{/* Quick stats or focus areas */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6 mb-12 animate-fade-in">
<div className="bg-white/5 backdrop-blur-sm rounded-2xl p-6 border border-white/10 hover:border-white/30 transition-all duration-300">
<Code2 className="mx-auto mb-3 text-slate-400" size={28} />
<div className="text-xs font-bold uppercase tracking-widest text-slate-300">
Code
</div>
</div>
<div className="bg-white/5 backdrop-blur-sm rounded-2xl p-6 border border-white/10 hover:border-white/30 transition-all duration-300">
<Wrench className="mx-auto mb-3 text-slate-400" size={28} />
<div className="text-xs font-bold uppercase tracking-widest text-slate-300">
Tools
</div>
</div>
<div className="bg-white/5 backdrop-blur-sm rounded-2xl p-6 border border-white/10 hover:border-white/30 transition-all duration-300">
<Terminal className="mx-auto mb-3 text-slate-400" size={28} />
<div className="text-xs font-bold uppercase tracking-widest text-slate-300">
Automation
</div>
</div>
<div className="bg-white/5 backdrop-blur-sm rounded-2xl p-6 border border-white/10 hover:border-white/30 transition-all duration-300">
<BookOpen className="mx-auto mb-3 text-slate-400" size={28} />
<div className="text-xs font-bold uppercase tracking-widest text-slate-300">
Learning
</div>
</div>
</div>
{/* Topics */}
<div className="mt-8 text-sm text-slate-400 animate-fade-in">
<span className="font-semibold text-slate-300">Topics:</span> Vibe
coding with AI • Debugging • Mac tools • Automation • Small scripts
• Learning notes • FOSS
</div>
</div>
</div>
</section>
);
};
-135
View File
@@ -1,135 +0,0 @@
"use client";
import * as React from "react";
import { motion, useScroll, useTransform } from "framer-motion";
import { Button } from "./Button";
import { AbstractCircuit } from "./Effects";
import { GlitchText } from "./GlitchText";
/**
* HeroSection: "Binary Architecture / The Blueprint"
*
* - **Concept**: The website as a technical blueprint being rendered in real-time.
* - **Typography**: Massive scale, mixing black Sans and technical Mono.
* - **Details**: Coordinate markers, blueprint lines, and binary data integration.
*/
export const HeroSection: React.FC = () => {
const { scrollY } = useScroll();
const y = useTransform(scrollY, [0, 800], [0, 300]);
const opacity = useTransform(scrollY, [0, 600], [1, 0]);
return (
<section className="relative min-h-[85dvh] md:min-h-[100dvh] flex items-center justify-center overflow-hidden bg-white px-5 md:px-0">
{/* 1. The Binary Architecture (Background) */}
<div className="absolute inset-0 z-0">
<AbstractCircuit />
</div>
{/* 2. Content Layer */}
<div className="relative z-10 container mx-auto h-full flex flex-col justify-center items-center">
<motion.div
style={{ y, opacity }}
className="text-center relative max-w-full md:max-w-[90vw]"
>
{/* Architectural Coordinate Labels */}
<div className="absolute -left-20 top-0 hidden xl:flex flex-col gap-8 opacity-20 pointer-events-none">
{[...Array(4)].map((_, i) => (
<div key={i} className="flex items-center gap-4">
<span className="text-[10px] font-mono">0x00{i}A</span>
<div className="w-12 h-[1px] bg-slate-400" />
</div>
))}
</div>
{/* Badge */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 1, delay: 0.2 }}
className="mb-4 md:mb-8 lg:mb-10 inline-flex items-center gap-3 md:gap-4 px-4 md:px-6 py-2 border border-slate-100 bg-white/40 backdrop-blur-sm rounded-full"
>
<div className="flex gap-1">
<div className="w-1 h-1 rounded-full bg-blue-500 animate-pulse" />
<div className="w-1 h-1 rounded-full bg-blue-300 animate-pulse delay-100" />
</div>
<span className="text-[9px] md:text-[10px] font-mono font-bold tracking-[0.3em] md:tracking-[0.4em] text-slate-500 uppercase">
Webentwicklung // Fixpreis
</span>
</motion.div>
{/* Headline */}
<h1 className="text-4xl md:text-8xl lg:text-[10rem] xl:text-[11rem] font-black tracking-tighter leading-[0.9] md:leading-[0.85] lg:leading-[0.8] text-slate-900 mb-6 md:mb-8 lg:mb-12 uppercase">
<div className="block">
<GlitchText delay={0.5} duration={1.2}>
Websites
</GlitchText>
</div>
<div
className="block font-mono text-transparent bg-clip-text bg-gradient-to-r from-slate-400 via-slate-300 to-slate-400 font-light italic"
style={{ letterSpacing: "-0.02em" }}
>
<GlitchText delay={0.9} duration={1}>
ohne overhead.
</GlitchText>
</div>
</h1>
{/* Subtext */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.8 }}
className="flex flex-col items-center gap-6 md:gap-8 lg:gap-12"
>
<p className="text-base md:text-xl lg:text-3xl text-slate-400 font-medium max-w-2xl leading-relaxed px-4">
Ohne Agentur, ohne Baukasten, ohne Stress.{" "}
<br className="hidden md:block" />
<span className="text-slate-900 font-bold tracking-tight">
Ein Entwickler. Fixpreis. Fertig.
</span>
</p>
<div className="flex flex-col md:flex-row items-center justify-center gap-4 md:gap-6 w-full px-5 md:px-0">
<Button href="/contact" size="large" className="w-full md:w-auto">
Kostenlos anfragen
</Button>
<Button
href="/websites"
variant="outline"
size="large"
className="w-full md:w-auto"
>
So funktioniert's
</Button>
</div>
</motion.div>
</motion.div>
</div>
{/* 3. Blueprint Frame (Decorative Borders) */}
<div className="absolute inset-0 pointer-events-none border-[1px] border-slate-100 m-4 md:m-8 opacity-40 md:opacity-40" />
<div className="absolute top-8 left-8 p-4 hidden md:block opacity-20 transform -rotate-90 origin-top-left transition-opacity hover:opacity-100 group">
<span className="text-[10px] font-mono tracking-widest text-slate-400">
MARC_MINTEL_DEV
</span>
</div>
<div className="absolute bottom-4 right-4 md:bottom-8 md:right-8 p-4 opacity-20 transition-opacity hover:opacity-100 scale-75 md:scale-100 origin-bottom-right">
<span className="text-[10px] font-mono tracking-widest text-slate-400">
FRANKFURT // 2026
</span>
</div>
{/* 4. Scroll Indicator */}
<motion.div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 flex flex-col items-center gap-3 opacity-40"
style={{ opacity }}
>
<div className="w-[1px] h-8 md:h-12 bg-slate-200" />
<span className="text-[9px] font-mono uppercase tracking-[0.3em] text-slate-400">
Scroll
</span>
</motion.div>
</section>
);
};
-110
View File
@@ -1,110 +0,0 @@
import React from "react";
import { Check, X } from "lucide-react";
import { ComponentShareButton } from "./ComponentShareButton";
import { Reveal } from "./Reveal";
interface IconListProps {
children: React.ReactNode;
title?: string;
className?: string;
showShare?: boolean;
}
interface IconListItemProps {
children: React.ReactNode;
icon?: React.ReactNode;
bullet?: boolean;
check?: boolean;
cross?: boolean;
className?: string;
iconClassName?: string;
iconContainerClassName?: string;
}
export const IconList: React.FC<IconListProps> = ({
children,
title,
className = "",
showShare = false,
}) => {
const shareId = `iconlist-${React.useId().replace(/:/g, "")}`;
return (
<Reveal direction="up" delay={0.1}>
<figure
id={shareId}
className={`not-prose my-12 group relative transition-all duration-500 ease-out z-10 ${className}`}
>
{/* Ambient Glow */}
<div className="absolute -inset-1 bg-gradient-to-r from-slate-100 to-white rounded-[2rem] blur opacity-10 group-hover:opacity-30 transition duration-1000 -z-10" />
<div className="glass bg-white/60 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative p-6 md:p-8">
{/* Subtle top shine */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
{/* Share Button top right */}
{showShare && (
<div className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-20">
<ComponentShareButton targetId={shareId} title={title || "Liste"} />
</div>
)}
{title && (
<h4 className="text-lg md:text-xl font-bold text-slate-800 mb-6 pb-4 border-b border-slate-100 tracking-tight pr-12 md:pr-0">
{title}
</h4>
)}
<ul className="space-y-4 m-0 p-0 list-none">
{children}
</ul>
</div>
</figure>
</Reveal>
);
};
export const IconListItem: React.FC<IconListItemProps> = ({
children,
icon,
bullet,
check,
cross,
className = "",
iconClassName = "",
iconContainerClassName = "",
}) => {
let renderIcon = icon;
if (bullet) {
renderIcon = (
<div className="w-2 h-2 bg-slate-400 rounded-full shrink-0 group-hover/item:bg-slate-900 group-hover/item:scale-125 transition-all duration-300 shadow-sm" />
);
} else if (check) {
renderIcon = (
<div className="w-6 h-6 rounded-lg bg-emerald-50 border border-emerald-100 flex items-center justify-center shrink-0 group-hover/item:bg-emerald-500 group-hover/item:border-emerald-500 transition-all duration-300 shadow-sm">
<Check strokeWidth={3} className="w-3.5 h-3.5 text-emerald-600 group-hover/item:text-white transition-colors" />
</div>
);
} else if (check === false || cross) {
renderIcon = (
<div className="w-6 h-6 rounded-lg bg-red-50 border border-red-100 flex items-center justify-center shrink-0 group-hover/item:bg-red-500 group-hover/item:border-red-500 transition-all duration-300 shadow-sm">
<X strokeWidth={3} className="w-3.5 h-3.5 text-red-500 group-hover/item:text-white transition-colors" />
</div>
);
}
return (
<li className={`flex items-start gap-4 m-0 group/item p-2 -mx-2 rounded-xl hover:bg-slate-50/50 transition-colors ${className}`}>
{renderIcon && (
<div
className={`shrink-0 flex items-center justify-center mt-0.5 ${iconContainerClassName} ${iconClassName}`}
>
{renderIcon}
</div>
)}
<div className="flex-1 text-slate-700 leading-relaxed font-serif text-[15px]">{children}</div>
</li>
);
};
-558
View File
@@ -1,558 +0,0 @@
"use client";
import * as React from "react";
import { cn } from "../utils/cn";
import { ShieldCheck, ArrowLeft, ArrowRight, RefreshCw } from "lucide-react";
import { MonoLabel, Label } from "./Typography";
import { motion, AnimatePresence } from "framer-motion";
interface IframeSectionProps {
src: string;
title?: string;
description?: string;
height?: string;
className?: string;
zoom?: number;
offsetY?: number;
clipHeight?: number;
browserFrame?: boolean;
allowScroll?: boolean;
desktopWidth?: number;
minimal?: boolean;
perspective?: boolean;
rotate?: number;
delay?: number;
noScale?: boolean;
dynamicGlow?: boolean;
minHeight?: number;
mobileWidth?: number;
mobileHeight?: string;
desktopHeight?: string;
}
/**
* Reusable Browser UI components to maintain consistency
*/
const BrowserChromeComponent: React.FC<{ url: string; minimal?: boolean }> = ({
url,
minimal,
}) => {
if (minimal) return null;
return (
<div
className="h-12 md:h-14 bg-slate-50 border-b border-slate-200/60 flex items-center px-4 md:px-6 gap-4 md:gap-8 z-[100] flex-shrink-0 relative isolation-auto"
style={{ isolation: "isolate", minHeight: "48px" }}
>
{/* 3D Rim Highlight */}
<div className="absolute top-0 left-0 right-0 h-[1px] bg-white opacity-80" />
{/* Status Indicators (Traffic Lights) - Enhanced with subtle depth */}
<div className="flex gap-1.5 md:gap-2.5">
{[
"bg-slate-300 from-slate-200 to-slate-400",
"bg-slate-300 from-slate-200 to-slate-400",
"bg-slate-300 from-slate-200 to-slate-400",
].map((cls, i) => (
<div
key={i}
className={cn(
"w-2.5 h-2.5 md:w-3 md:h-3 rounded-full bg-gradient-to-br shadow-[inset_0_1px_2px_rgba(0,0,0,0.1),0_1.5px_1px_rgba(255,255,255,0.7)] relative flex items-center justify-center",
cls,
)}
>
{/* Subtle glint, soft blur */}
<div className="absolute top-[20%] left-[20%] w-[30%] h-[30%] rounded-full bg-white/30 blur-[0.7px]" />
</div>
))}
</div>
{/* Navigation Controls - Hidden on mobile */}
<div className="hidden lg:flex items-center gap-4 opacity-30">
<ArrowLeft className="w-3.5 h-3.5" />
<ArrowRight className="w-3.5 h-3.5" />
<RefreshCw className="w-3 h-3 ml-1" />
</div>
{/* URL Bar - Solid high-fidelity layer instead of expensive backdrop-blur */}
<div className="flex-1 max-w-[700px] mx-auto bg-white shadow-[inset_0_1px_3px_rgba(0,0,0,0.06),0_0_0_1px_rgba(255,255,255,0.4)] rounded-xl flex items-center justify-between px-3 md:px-6 h-8 md:h-9 border border-slate-200/40 group">
<div className="flex items-center gap-2 md:gap-3 opacity-60 group-hover:opacity-100 transition-opacity duration-700 overflow-hidden">
<ShieldCheck className="w-3 md:w-3.5 h-3 md:h-3.5 text-blue-500 shrink-0" />
<span className="text-[9px] md:text-[10px] font-mono font-bold tracking-[0.1em] md:tracking-[0.2em] uppercase truncate whitespace-nowrap text-slate-900 md:border-r border-slate-200 md:pr-4 md:mr-1">
{url.replace("varnish-cache://", "")}
</span>
<span className="text-[8px] font-mono text-slate-400 tracking-widest hidden xl:block">
VARNISH_TUNNEL // ALPHA_NODE_04
</span>
</div>
<div className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse hidden sm:block shadow-[0_0_8px_rgba(74,222,128,0.4)]" />
</div>
{/* Industrial Accent / Technical ID - Hidden on mobile */}
<div className="hidden md:flex flex-col items-end opacity-20 pointer-events-none select-none">
<div className="text-[6px] font-mono font-bold tracking-widest uppercase mb-0.5">
SECURE_LINK // ID: VARNISH_4X
</div>
<div className="w-12 h-0.5 bg-slate-900 rounded-full" />
</div>
</div>
);
};
const BrowserChrome = React.memo(BrowserChromeComponent);
BrowserChrome.displayName = "BrowserChrome";
export const IframeSection: React.FC<IframeSectionProps> = ({
src,
title,
description,
height = "500px",
className,
zoom,
offsetY = 0,
clipHeight,
browserFrame = false,
allowScroll = false,
desktopWidth = 1200,
minimal = false,
perspective = false,
rotate: _rotate = 0,
delay: _delay = 0,
noScale = false,
dynamicGlow = true,
minHeight = 400,
mobileWidth = 390,
mobileHeight,
desktopHeight,
}) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const iframeRef = React.useRef<HTMLIFrameElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [scale, setScale] = React.useState(1);
const [activeInternalWidth, setActiveInternalWidth] =
React.useState(desktopWidth);
const [isLoading, setIsLoading] = React.useState(true);
const [isIframeLoaded, setIsIframeLoaded] = React.useState(false);
const [glowColors, setGlowColors] = React.useState<string[]>([
"rgba(148, 163, 184, 0.1)",
"rgba(148, 163, 184, 0.1)",
"rgba(148, 163, 184, 0.1)",
"rgba(148, 163, 184, 0.1)",
]);
const [scrollState, setScrollState] = React.useState({
atTop: true,
atBottom: false,
isScrollable: false,
});
const [headerHeightPx, setHeaderHeightPx] = React.useState(0);
// Responsive Header Height Calculation
React.useEffect(() => {
if (browserFrame && !minimal) {
const updateHeaderHeight = () => {
setHeaderHeightPx(window.innerWidth < 768 ? 48 : 56);
};
updateHeaderHeight();
window.addEventListener("resize", updateHeaderHeight);
return () => window.removeEventListener("resize", updateHeaderHeight);
} else {
setHeaderHeightPx(0);
}
}, [browserFrame, minimal]);
// Scaling & Adaptive Viewport Logic
React.useEffect(() => {
if (!containerRef.current || noScale) {
setScale(1);
setActiveInternalWidth(desktopWidth);
return;
}
const updateDimensions = () => {
if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth;
if (containerWidth > 0) {
// Adaptive threshold: Switch to mobile width if container is small
const useMobile = containerWidth < 500;
const internalWidth = useMobile ? mobileWidth : desktopWidth;
setActiveInternalWidth(internalWidth);
// Calculate scale based on the active target width
const newScale = zoom || containerWidth / internalWidth;
setScale(newScale);
}
}
};
updateDimensions();
const observer = new ResizeObserver(updateDimensions);
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [desktopWidth, mobileWidth, zoom, noScale]);
const updateScrollState = React.useCallback(() => {
try {
const win = iframeRef.current?.contentWindow;
const doc = iframeRef.current?.contentDocument?.documentElement;
if (doc && win) {
const scrollTop = doc.scrollTop || win.scrollY || 0;
const atTop = scrollTop <= 5;
const atBottom =
scrollTop + doc.clientHeight >= doc.scrollHeight - 5;
const isScrollable = doc.scrollHeight > doc.clientHeight + 10;
setScrollState({ atTop, atBottom, isScrollable });
}
} catch (_e) {}
}, []);
// Ambilight effect (sampled from iframe if same-origin)
const updateAmbilight = React.useCallback(() => {
if (!dynamicGlow || !iframeRef.current || !canvasRef.current) return;
try {
const iframe = iframeRef.current;
const doc = iframe.contentDocument;
if (!doc) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
canvas.width = 100;
canvas.height = 100;
const body = doc.body;
const computedStyle = window.getComputedStyle(body);
const bgColor = computedStyle.backgroundColor || "rgba(255,255,255,1)";
const sampleX = (x: number, y: number) => {
const el = doc.elementFromPoint(x, y);
if (el) return window.getComputedStyle(el).backgroundColor;
return bgColor;
};
const w = doc.documentElement.scrollWidth || iframe.offsetWidth;
const h = doc.documentElement.scrollHeight || iframe.offsetHeight;
const sampleMargin = 20;
const colors = [
sampleX(w / 2, sampleMargin + offsetY),
sampleX(w - sampleMargin, h / 2 + offsetY),
sampleX(w / 2, h - sampleMargin + offsetY),
sampleX(sampleMargin, h / 2 + offsetY),
];
setGlowColors(
colors.map((c) => {
if (!c || c === "transparent") return "rgba(148, 163, 184, 0.1)";
return c.replace("rgb(", "rgba(").replace(")", ", 0.5)");
}),
);
updateScrollState();
} catch (_e) {}
}, [dynamicGlow, offsetY, updateScrollState]);
// Height parse helper
const parseNumericHeight = (h: string | number) => {
if (typeof h === "number") return h;
const match = h.match(/^(\d+(?:\.\d+)?)(px)$/);
return match ? parseFloat(match[1]) : null;
};
const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
const activeHeight = isMobile
? mobileHeight || height
: desktopHeight || height;
const baseNumericHeight = parseNumericHeight(activeHeight);
const finalScaledHeight = clipHeight
? clipHeight * scale
: baseNumericHeight
? baseNumericHeight * scale
: null;
const chassisStyle = {
height:
activeHeight === "100%"
? "100%"
: finalScaledHeight
? `${finalScaledHeight + headerHeightPx}px`
: `calc(${activeHeight} + ${headerHeightPx}px)`,
minHeight: minHeight ? `${minHeight}px` : undefined,
};
const [loadingPhase, setLoadingPhase] = React.useState(0);
const loadingPhases = [
"DETERMINING ROUTE",
"INITIALIZING HANDSHAKE",
"ESTABLISHING ENCRYPTED LINK",
"SYNCING ASSETS",
"FINALIZING...",
];
// 1. Safety Trigger: Force-stop loading after 2.5s no matter what
React.useEffect(() => {
if (!isLoading) return;
const timer = setTimeout(() => {
setIsLoading(false);
}, 2500);
return () => clearTimeout(timer);
}, [isLoading]);
// 2. Sync Trigger: Cleanup when BOTH phases and iframe load complete
React.useEffect(() => {
if (!isLoading) return;
// Early exit: if iframe is already loaded, we only need the first 2 "handshake" phases
const phasesRequired = isIframeLoaded
? loadingPhases.length - 2
: loadingPhases.length - 1;
if (loadingPhase >= phasesRequired) {
const timer = setTimeout(() => {
setIsLoading(false);
}, 300);
return () => clearTimeout(timer);
}
}, [isLoading, isIframeLoaded, loadingPhase, loadingPhases.length]);
// 3. Phase Incrementer (Faster: 400ms)
React.useEffect(() => {
if (!isLoading) return;
const interval = setInterval(() => {
setLoadingPhase((prev) => {
if (prev < loadingPhases.length - 1) return prev + 1;
clearInterval(interval);
return prev;
});
}, 400);
return () => clearInterval(interval);
}, [isLoading, loadingPhases.length]);
return (
<div
className={cn(
"w-full group relative",
!minimal && "space-y-6",
className,
)}
style={className?.includes("h-full") ? { height: "100%" } : {}}
>
<canvas ref={canvasRef} className="hidden" aria-hidden="true" />
{!minimal && (title || description) && (
<div className="space-y-2 px-1">
{title && (
<h4 className="text-2xl font-bold text-slate-900 tracking-tight leading-none">
{title}
</h4>
)}
{description && (
<p className="text-slate-400 text-sm font-medium">{description}</p>
)}
</div>
)}
{/* Main Device Chassis */}
<div
ref={containerRef}
className={cn(
"w-full relative flex flex-col z-10",
minimal ? "bg-transparent" : "bg-slate-50",
!minimal &&
"rounded-[2.5rem] border border-slate-200/50 shadow-[0_80px_160px_-40px_rgba(0,0,0,0.18),0_0_1px_rgba(0,0,0,0.1)]",
perspective &&
"hover:scale-[1.03] hover:-translate-y-3 transition-[transform,shadow,scale] duration-1000 ease-[cubic-bezier(0.23,1,0.32,1)]",
"overflow-hidden",
)}
style={chassisStyle}
>
{/* AMBILIGHT DYNAMIC GLOW */}
{dynamicGlow && (
<div className="absolute -inset-[30%] blur-[140px] opacity-30 group-hover:opacity-90 transition-all duration-[2000ms] pointer-events-none z-0">
<div
className="absolute inset-0 rounded-[6rem]"
style={{
background: `
radial-gradient(circle at 50% 10%, ${glowColors[0]} 0%, transparent 60%),
radial-gradient(circle at 95% 50%, ${glowColors[1]} 0%, transparent 60%),
radial-gradient(circle at 50% 90%, ${glowColors[2]} 0%, transparent 60%),
radial-gradient(circle at 5% 50%, ${glowColors[3]} 0%, transparent 60%)
`,
filter: "saturate(2.2) brightness(1.1)",
}}
/>
</div>
)}
{/* Browser Frame */}
{browserFrame && (
<BrowserChrome
url="varnish-cache://secure.klz-cables.com"
minimal={minimal}
/>
)}
{/* Scaled Viewport Container - Solid bg prevents gray flicker */}
<div className="flex-1 relative overflow-hidden bg-white">
{/* Artificial Loader Overlay */}
<AnimatePresence>
{isLoading && (
<motion.div
initial={{ opacity: 1 }}
exit={{
opacity: 0,
transition: { duration: 0.6, ease: [0.23, 1, 0.32, 1] },
}}
className="absolute inset-0 flex items-center justify-center bg-white/95 backdrop-blur-3xl z-50 pointer-events-none transform-gpu"
>
{/* Static Binary Matrix - Stop redundant re-paints */}
<div
className="absolute inset-0 opacity-[0.03] select-none pointer-events-none font-mono text-[8px] p-4 flex flex-col gap-1 overflow-hidden"
aria-hidden="true"
>
{Array.from({ length: 24 }).map((_, i) => (
<div
key={i}
className="whitespace-nowrap tracking-[0.5em] opacity-50"
>
{"01011010010110101101011010101011010101011010101101010101101010"
.split("")
.join(" ")}
</div>
))}
</div>
<div className="flex flex-col items-center gap-6 relative z-10 w-full max-w-xs px-8">
<div className="w-full h-1 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{
width: `${((loadingPhase + 1) / loadingPhases.length) * 100}%`,
}}
className="h-full bg-slate-900"
/>
</div>
<div className="flex flex-col items-center gap-2">
<MonoLabel className="text-[10px] text-slate-900 font-bold uppercase tracking-[0.3em] flex items-center gap-2">
<span className="w-1 h-1 rounded-full bg-blue-500 animate-pulse" />
{loadingPhases[loadingPhase]}
</MonoLabel>
<Label className="text-[8px] text-slate-400 font-mono uppercase tracking-widest">
Varnish_Tunnel // Alpha_Node
</Label>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<div
className={cn(
"transition-all duration-1000 ease-[cubic-bezier(0.23,1,0.32,1)] origin-top-left absolute left-0 right-0 transform-gpu",
noScale && "relative w-full h-full",
)}
style={{
width: noScale ? "100%" : `${activeInternalWidth}px`,
transform: noScale ? "none" : `scale(${scale})`,
height: noScale ? "100%" : `${100 / scale}%`,
willChange: "opacity",
}}
>
<motion.div
initial={false}
animate={{
opacity: isLoading ? 0 : 1,
}}
transition={{
duration: 0.6,
ease: [0.23, 1, 0.32, 1],
}}
className="w-full h-full relative z-0"
>
<iframe
ref={iframeRef}
src={src}
scrolling={allowScroll ? "yes" : "no"}
className="w-full h-full border-none no-scrollbar"
onLoad={(e) => {
setIsIframeLoaded(true);
try {
const iframe = e.currentTarget;
if (iframe.contentDocument) {
const style =
iframe.contentDocument.createElement("style");
style.textContent = `
*::-webkit-scrollbar { display: none !important; }
* { -ms-overflow-style: none !important; scrollbar-width: none !important; }
`;
iframe.contentDocument.head.appendChild(style);
setTimeout(updateAmbilight, 600);
const onScroll = () => {
requestAnimationFrame(updateAmbilight);
updateScrollState();
};
iframe.contentWindow?.addEventListener(
"scroll",
onScroll,
{
passive: true,
},
);
}
} catch (_e) {
// ignore
}
}}
style={{
transform: `translateY(-${offsetY}px)`,
height: `calc(100% + ${offsetY}px)`,
pointerEvents: allowScroll ? "auto" : "none",
width: "calc(100% + 20px)", // Bleed for seamless edge
marginLeft: "-10px",
}}
title={title || "Project Display"}
/>
</motion.div>
</div>
{/* Custom Industrial Scroll Indicator */}
{allowScroll && scrollState.isScrollable && (
<div className="absolute right-4 top-1/2 -translate-y-1/2 w-1 h-32 bg-slate-200/20 rounded-full z-20 backdrop-blur-sm">
<div
className="w-full bg-slate-900 rounded-full transition-all duration-150 ease-out shadow-[0_0_12px_rgba(15,23,42,0.1)]"
style={{
height: "30px",
transform: `translateY(${(() => {
try {
const doc =
iframeRef.current?.contentDocument?.documentElement;
if (!doc) return 0;
const scrollPct =
doc.scrollTop / (doc.scrollHeight - doc.clientHeight);
return scrollPct * (128 - 30);
} catch (e) {
return 0;
}
})()}px)`,
}}
/>
</div>
)}
</div>
</div>
</div>
);
};
-42
View File
@@ -1,42 +0,0 @@
'use client';
import React from 'react';
interface ImageTextProps {
image: string;
title: string;
children: React.ReactNode;
reversed?: boolean;
className?: string;
}
export const ImageText: React.FC<ImageTextProps> = ({
image,
title,
children,
reversed = false,
className = '',
}) => {
return (
<div className={`not-prose my-16 grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16 items-center ${className}`}>
{/* Image Side */}
<div className={`relative ${reversed ? 'md:order-2' : ''}`}>
<div className="absolute -inset-2 border-2 border-slate-100 rounded-2xl" />
<img
src={image}
alt={title}
className="relative w-full h-auto rounded-xl border border-slate-200 bg-white object-cover shadow-sm"
loading="lazy"
/>
</div>
{/* Text Side */}
<div className={`${reversed ? 'md:order-1' : ''}`}>
<h3 className="text-xl font-bold text-slate-900 mb-6 uppercase tracking-wider">{title}</h3>
<div className="prose prose-slate prose-sm text-slate-600 leading-relaxed font-medium">
{children}
</div>
</div>
</div>
);
};
+28 -17
View File
@@ -1,6 +1,6 @@
'use client';
"use client";
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState } from "react";
export const InteractiveElements: React.FC = () => {
const [progress, setProgress] = useState(0);
@@ -9,8 +9,9 @@ export const InteractiveElements: React.FC = () => {
useEffect(() => {
const handleScroll = () => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const docHeight =
document.documentElement.scrollHeight - window.innerHeight;
if (docHeight > 0) {
setProgress((scrollTop / docHeight) * 100);
}
@@ -18,34 +19,44 @@ export const InteractiveElements: React.FC = () => {
setShowBackToTop(scrollTop > 300);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<>
{/* Reading Progress Bar */}
<div
className="reading-progress-bar"
style={{
<div
className="reading-progress-bar"
style={{
transform: `scaleX(${progress / 100})`,
display: 'block'
}}
display: "block",
}}
/>
{/* Floating Back to Top Button */}
<button
<button
onClick={scrollToTop}
className={`floating-back-to-top ${showBackToTop ? 'visible' : ''}`}
className={`floating-back-to-top ${showBackToTop ? "visible" : ""}`}
aria-label="Back to top"
style={{ display: 'flex' }}
style={{ display: "flex" }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
</button>
</>
@@ -1,371 +0,0 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
interface LineProps {
className?: string;
delay?: number;
}
export const HeroLines: React.FC<LineProps> = ({
className = "",
delay = 0,
}) => {
return (
<svg
className={`absolute pointer-events-none ${className}`}
viewBox="0 0 800 600"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<motion.path
d="M-100 300 C 100 300, 200 100, 400 100 C 600 100, 700 500, 900 500"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
className="text-slate-200"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 2, delay: delay, ease: "easeInOut" }}
/>
<motion.path
d="M-100 350 C 100 350, 200 150, 400 150 C 600 150, 700 550, 900 550"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
className="text-slate-100"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 2.5, delay: delay + 0.2, ease: "easeInOut" }}
/>
{/* Animated Pulses */}
<motion.circle r="3" fill="currentColor" className="text-slate-300">
<animateMotion
dur="6s"
repeatCount="indefinite"
path="M-100 300 C 100 300, 200 100, 400 100 C 600 100, 700 500, 900 500"
/>
</motion.circle>
<motion.circle r="3" fill="currentColor" className="text-slate-200">
<animateMotion
dur="8s"
repeatCount="indefinite"
path="M-100 350 C 100 350, 200 150, 400 150 C 600 150, 700 550, 900 550"
/>
</motion.circle>
{/* Nodes */}
<motion.circle
cx="400"
cy="100"
r="4"
className="fill-slate-200"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: delay + 1, duration: 0.5 }}
/>
<motion.circle
cx="400"
cy="150"
r="4"
className="fill-slate-100"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: delay + 1.2, duration: 0.5 }}
/>
</svg>
);
};
export const GridLines: React.FC<LineProps> = ({
className = "",
delay = 0,
}) => {
return (
<svg
className={`absolute pointer-events-none ${className}`}
viewBox="0 0 400 400"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path
d="M 40 0 L 0 0 0 40"
fill="none"
stroke="currentColor"
strokeWidth="0.5"
className="text-slate-100"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* Highlighted Path */}
<motion.path
d="M 40 40 L 120 40 L 120 120 L 200 120"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-slate-300"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 1.5, delay: delay }}
/>
{/* Active Cells */}
<motion.rect
x="120"
y="40"
width="40"
height="40"
className="fill-slate-50"
initial={{ opacity: 0 }}
animate={{ opacity: [0, 0.5, 0] }}
transition={{ duration: 3, repeat: Infinity, repeatDelay: 2 }}
/>
<motion.rect
x="160"
y="80"
width="40"
height="40"
className="fill-slate-50"
initial={{ opacity: 0 }}
animate={{ opacity: [0, 0.5, 0] }}
transition={{ duration: 4, repeat: Infinity, repeatDelay: 1 }}
/>
<motion.circle
cx="200"
cy="120"
r="3"
className="fill-slate-400"
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 1.5 }}
/>
</svg>
);
};
export const FlowLines: React.FC<LineProps> = ({
className = "",
delay = 0,
}) => {
return (
<svg
className={`absolute pointer-events-none ${className}`}
viewBox="0 0 600 200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<motion.path
d="M 0 100 H 100 C 150 100, 150 50, 200 50 H 300"
stroke="currentColor"
strokeWidth="1"
className="text-slate-200"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 1.5, delay: delay }}
/>
<motion.path
d="M 0 100 H 100 C 150 100, 150 150, 200 150 H 300"
stroke="currentColor"
strokeWidth="1"
className="text-slate-200"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 1.5, delay: delay + 0.2 }}
/>
{/* Pulse */}
<motion.circle r="2" fill="currentColor" className="text-slate-400">
<animateMotion
dur="4s"
repeatCount="indefinite"
path="M 0 100 H 100 C 150 100, 150 50, 200 50 H 300"
/>
</motion.circle>
<motion.rect
x="300"
y="30"
width="80"
height="40"
rx="8"
className="stroke-slate-300 fill-white"
strokeWidth="1"
initial={{ opacity: 0, x: 280 }}
whileInView={{ opacity: 1, x: 300 }}
viewport={{ once: true }}
transition={{ delay: delay + 1 }}
/>
<motion.rect
x="300"
y="130"
width="80"
height="40"
rx="8"
className="stroke-slate-300 fill-white"
strokeWidth="1"
initial={{ opacity: 0, x: 280 }}
whileInView={{ opacity: 1, x: 300 }}
viewport={{ once: true }}
transition={{ delay: delay + 1.2 }}
/>
</svg>
);
};
export const CirclePattern: React.FC<LineProps> = ({
className = "",
delay = 0,
}) => {
return (
<svg
className={`absolute pointer-events-none ${className}`}
viewBox="0 0 400 400"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<motion.circle
cx="200"
cy="200"
r="100"
stroke="currentColor"
strokeWidth="1"
className="text-slate-100"
initial={{ scale: 0.8, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 1, delay: delay }}
/>
<motion.circle
cx="200"
cy="200"
r="150"
stroke="currentColor"
strokeWidth="1"
className="text-slate-50"
initial={{ scale: 0.8, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 1, delay: delay + 0.2 }}
/>
<motion.circle
cx="200"
cy="200"
r="50"
stroke="currentColor"
strokeWidth="1"
className="text-slate-200"
initial={{ scale: 0.8, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 1, delay: delay + 0.4 }}
/>
{/* Rotating Ring */}
<motion.circle
cx="200"
cy="200"
r="120"
stroke="currentColor"
strokeWidth="1"
strokeDasharray="10 10"
className="text-slate-200"
animate={{ rotate: 360 }}
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
/>
</svg>
);
};
export const ServicesFlow: React.FC<LineProps> = ({
className = "",
delay = 0,
}) => {
return (
<svg
className={`absolute pointer-events-none ${className}`}
viewBox="0 0 1000 200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Path connecting the 3 steps */}
<motion.path
d="M 100 100 L 900 100"
stroke="currentColor"
strokeWidth="3"
strokeDasharray="8 8"
className="text-slate-300"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 2, delay: delay }}
/>
{/* Animated pulse moving along the line */}
<motion.circle r="5" fill="currentColor" className="text-slate-900">
<animateMotion
dur="3s"
repeatCount="indefinite"
path="M 100 100 L 900 100"
/>
</motion.circle>
{/* Second pulse with delay */}
<motion.circle r="5" fill="currentColor" className="text-slate-900">
<animateMotion
dur="3s"
begin="1.5s"
repeatCount="indefinite"
path="M 100 100 L 900 100"
/>
</motion.circle>
</svg>
);
};
export const ComparisonLines: React.FC<LineProps> = ({
className = "",
delay = 0,
}) => {
return (
<svg
className={`absolute pointer-events-none ${className}`}
viewBox="0 0 100 400"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<motion.path
d="M 50 0 V 400"
stroke="currentColor"
strokeWidth="3"
strokeDasharray="4 4"
className="text-slate-300"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 1.5, delay: delay }}
/>
<motion.circle r="5" fill="currentColor" className="text-slate-900">
<animateMotion dur="4s" repeatCount="indefinite" path="M 50 0 V 400" />
</motion.circle>
</svg>
);
};
export const ConnectorStart: React.FC<LineProps> = (_props) => null;
export const ConnectorBranch: React.FC<LineProps> = (_props) => null;
export const ConnectorSplit: React.FC<LineProps> = (_props) => null;
export const ConnectorEnd: React.FC<LineProps> = (_props) => null;
@@ -1,150 +0,0 @@
import * as React from "react";
import { ArrowRight, Check, X } from "lucide-react";
import { Reveal } from "../Reveal";
import { Label, H3, LeadText } from "../Typography";
import { Strikethrough } from "../Strikethrough";
import { cn } from "../../utils/cn";
import { ComponentShareButton } from "../ComponentShareButton";
interface ComparisonRowProps {
description?: string;
// Legacy props
negativeLabel?: string;
negativeText?: string;
positiveLabel?: string;
positiveText?: React.ReactNode;
// New props for lists
leftTitle?: string;
leftItems?: string[];
rightTitle?: string;
rightItems?: string[];
reverse?: boolean;
delay?: number;
showShare?: boolean;
}
export const ComparisonRow: React.FC<ComparisonRowProps> = ({
description,
negativeLabel,
negativeText,
positiveLabel,
positiveText,
leftTitle,
leftItems,
rightTitle,
rightItems,
reverse = false,
delay = 0,
showShare = false,
}) => {
const shareId = `comprow-${React.useId().replace(/:/g, "")}`;
// Normalize inputs
const labelLeft = leftTitle || negativeLabel;
const contentLeft = leftItems || negativeText;
const labelRight = rightTitle || positiveLabel;
const contentRight = rightItems || positiveText;
// Helper to render left side content (Strikethrough)
const renderLeft = () => {
if (Array.isArray(contentLeft)) {
return (
<ul className="space-y-3">
{contentLeft.map((item, i) => (
<li key={i} className="flex items-start gap-2">
<X className="w-4 h-4 text-red-400 mt-1 shrink-0" />
<Strikethrough delay={delay + 0.2 + (i * 0.1)} color="rgba(220, 50, 50, 0.6)">
{item}
</Strikethrough>
</li>
))}
</ul>
);
}
if (typeof contentLeft === "string") {
return (
<LeadText className="leading-snug">
<Strikethrough delay={delay + 0.3}>{contentLeft}</Strikethrough>
</LeadText>
);
}
return null;
};
// Helper to render right side content (Positive)
const renderRight = () => {
if (Array.isArray(contentRight)) {
return (
<ul className="space-y-3">
{contentRight.map((item, i) => (
<li key={i} className="flex items-start gap-2">
<Check className="w-4 h-4 text-green-500 mt-1 shrink-0" />
<span className="text-slate-700 font-medium">
{item}
</span>
</li>
))}
</ul>
);
}
// Default / Legacy positiveText (usually a Node or string)
return <H3 className="text-2xl md:text-3xl">{contentRight}</H3>;
};
return (
<Reveal delay={delay}>
<div id={shareId} className="not-prose space-y-4 my-8 group relative z-10">
{showShare && (
<div className="absolute top-0 right-0 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
<ComponentShareButton targetId={shareId} title={description || "Vergleich"} />
</div>
)}
{description && (
<Label className="text-slate-400 text-[10px] tracking-[0.2em] uppercase">
{description}
</Label>
)}
<div
className={cn(
"flex flex-col gap-8 md:gap-12 items-stretch", // altered alignment
reverse ? "md:flex-row-reverse" : "md:flex-row",
)}
>
{/* Left / Negative Side */}
<div className="flex-1 p-8 md:p-10 bg-slate-50/50 rounded-2xl text-slate-400 border border-transparent w-full">
<Label className="mb-6 text-red-900/40 font-bold tracking-widest uppercase text-xs">
{labelLeft}
</Label>
{renderLeft()}
</div>
<div className="shrink-0 flex items-center justify-center">
<ArrowRight
className={cn(
"w-6 h-6 text-slate-200 hidden md:block",
reverse ? "rotate-180" : "",
)}
/>
</div>
{/* Right / Positive Side */}
<div className="flex-1 p-8 md:p-10 border border-slate-100 rounded-2xl bg-white hover:border-slate-200 transition-all duration-500 hover:shadow-xl hover:shadow-slate-100/50 w-full relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Check className="w-12 h-12 text-green-500" />
</div>
<Label className="mb-6 text-green-600 font-bold tracking-widest uppercase text-xs">
{labelRight}
</Label>
{renderRight()}
</div>
</div>
</div>
</Reveal>
);
};
@@ -1,3 +0,0 @@
'use client';
export * from './Illustrations';
@@ -1,313 +0,0 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
interface IllustrationProps {
className?: string;
delay?: number;
}
export const DirectCommunication: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.circle
cx="20" cy="60" r="4"
className="fill-slate-400"
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true }}
transition={{ delay: delay }}
/>
<motion.circle
cx="100" cy="60" r="4"
className="fill-slate-900"
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 0.2 }}
/>
<motion.path
d="M 24 60 H 96"
stroke="currentColor"
strokeWidth="1"
className="text-slate-300"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 1, delay: delay + 0.4 }}
/>
<motion.circle r="2" className="fill-slate-400">
<animateMotion
dur="3s"
repeatCount="indefinite"
path="M 24 60 H 96"
/>
</motion.circle>
</svg>
);
export const FastPrototyping: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.rect
x="20" y="30" width="80" height="60" rx="4"
stroke="currentColor" strokeWidth="1"
className="text-slate-300"
initial={{ pathLength: 0, opacity: 0 }}
whileInView={{ pathLength: 1, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 1, delay: delay }}
/>
<motion.path
d="M 20 45 H 100"
stroke="currentColor" strokeWidth="1"
className="text-slate-200"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: delay + 0.5 }}
/>
<motion.path
d="M 45 55 L 75 65 L 45 75 Z"
className="fill-slate-200 stroke-slate-300"
strokeWidth="1"
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 0.8 }}
/>
<motion.path
d="M 85 37 H 90"
stroke="currentColor" strokeWidth="1"
className="text-slate-300"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 1 }}
/>
</svg>
);
export const CleanCode: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.path
d="M 30 40 H 70"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
className="text-slate-300"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: delay }}
/>
<motion.path
d="M 30 55 H 90"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
className="text-slate-200"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: delay + 0.2 }}
/>
<motion.path
d="M 30 70 H 60"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
className="text-slate-300"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: delay + 0.4 }}
/>
<motion.path
d="M 30 85 H 80"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
className="text-slate-200"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: delay + 0.6 }}
/>
<motion.path
d="M 85 45 L 95 55 L 110 35"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
className="text-slate-900"
initial={{ pathLength: 0, opacity: 0 }}
whileInView={{ pathLength: 1, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: delay + 1 }}
/>
</svg>
);
export const FixedPrice: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.rect
x="35" y="35" width="50" height="50" rx="2"
stroke="currentColor" strokeWidth="1"
className="text-slate-300"
initial={{ pathLength: 0, opacity: 0 }}
whileInView={{ pathLength: 1, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 1, delay: delay }}
/>
<motion.path
d="M 45 50 H 75 M 45 60 H 75 M 45 70 H 65"
stroke="currentColor" strokeWidth="1"
className="text-slate-200"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: delay + 0.5 }}
/>
<motion.circle
cx="85" cy="35" r="12"
className="fill-white stroke-slate-900"
strokeWidth="1"
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true }}
transition={{ type: "spring", stiffness: 200, damping: 10, delay: delay + 1 }}
/>
<motion.path
d="M 80 35 L 83 38 L 90 31"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
className="text-slate-900"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: delay + 1.3 }}
/>
</svg>
);
export const WebsitesIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.rect x="20" y="30" width="80" height="60" rx="2" stroke="currentColor" strokeWidth="1" className="text-slate-300"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ duration: 1, delay }} />
<motion.path d="M 20 42 H 100" stroke="currentColor" strokeWidth="1" className="text-slate-200"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ delay: delay + 0.5 }} />
<motion.rect x="30" y="50" width="25" height="30" rx="1" stroke="currentColor" strokeWidth="1" className="text-slate-200"
initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} viewport={{ once: true }} transition={{ delay: delay + 0.8 }} />
<motion.path d="M 65 55 H 90 M 65 65 H 90 M 65 75 H 80" stroke="currentColor" strokeWidth="1" className="text-slate-200"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ delay: delay + 1 }} />
</svg>
);
export const SystemsIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.path d="M 40 30 L 20 40 V 80 L 40 90 L 60 80 V 40 L 40 30 Z" stroke="currentColor" strokeWidth="1" className="text-slate-300"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ duration: 1, delay }} />
<motion.path d="M 80 50 L 60 60 V 100 L 80 110 L 100 100 V 60 L 80 50 Z" stroke="currentColor" strokeWidth="1" className="text-slate-300"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ duration: 1, delay: delay + 0.3 }} />
<motion.path d="M 60 60 L 40 50" stroke="currentColor" strokeWidth="1" strokeDasharray="2 2" className="text-slate-300"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ delay: delay + 0.8 }} />
</svg>
);
export const AutomationIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.circle cx="45" cy="45" r="15" stroke="currentColor" strokeWidth="1" className="text-slate-300"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ duration: 1, delay }} />
<motion.circle cx="75" cy="75" r="15" stroke="currentColor" strokeWidth="1" className="text-slate-300"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ duration: 1, delay: delay + 0.2 }} />
<motion.path d="M 55 55 L 65 65" stroke="currentColor" strokeWidth="1" className="text-slate-300"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ delay: delay + 0.5 }} />
<motion.path d="M 62 58 L 65 65 L 58 62" stroke="currentColor" strokeWidth="1" className="text-slate-300"
initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} viewport={{ once: true }} transition={{ delay: delay + 0.7 }} />
</svg>
);
export const MinimalistArchitect: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.path
d="M 40 160 V 60 L 100 30 L 160 60 V 160"
stroke="currentColor" strokeWidth="1"
className="text-slate-300"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 1.5, delay: delay }}
/>
<motion.path
d="M 40 100 H 160 M 100 30 V 160"
stroke="currentColor" strokeWidth="0.5"
className="text-slate-200"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 1, delay: delay + 0.5 }}
/>
<motion.rect
x="85" y="120" width="30" height="40"
stroke="currentColor" strokeWidth="1"
className="text-slate-300"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: delay + 1, duration: 0.5 }}
/>
</svg>
);
export const DifferenceIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 200 100" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Messy Path */}
<motion.path
d="M 20 50 C 30 20, 40 80, 50 50 C 60 20, 70 80, 80 50"
stroke="currentColor" strokeWidth="1" className="text-slate-200"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ duration: 2, delay }}
/>
{/* Arrow to clean side */}
<motion.path
d="M 90 50 H 110"
stroke="currentColor" strokeWidth="1" className="text-slate-300"
initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} viewport={{ once: true }} transition={{ delay: delay + 1 }}
/>
{/* Clean Path */}
<motion.path
d="M 120 50 H 180"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-slate-900"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ duration: 1, delay: delay + 1.5 }}
/>
</svg>
);
export const TargetGroupIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.circle cx="60" cy="40" r="20" stroke="currentColor" strokeWidth="1" className="text-slate-200"
initial={{ scale: 0 }} whileInView={{ scale: 1 }} viewport={{ once: true }} transition={{ delay }} />
<motion.path d="M 30 90 C 30 70, 90 70, 90 90" stroke="currentColor" strokeWidth="1" className="text-slate-200"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ delay: delay + 0.3 }} />
<motion.path d="M 50 20 L 60 10 L 70 20" stroke="currentColor" strokeWidth="1" className="text-slate-300"
initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} viewport={{ once: true }} transition={{ delay: delay + 0.6 }} />
</svg>
);
export const ContactIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.rect x="20" y="35" width="80" height="50" rx="2" stroke="currentColor" strokeWidth="1" className="text-slate-300"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ duration: 1, delay }} />
<motion.path d="M 20 35 L 60 65 L 100 35" stroke="currentColor" strokeWidth="1" className="text-slate-200"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ duration: 0.8, delay: delay + 0.5 }} />
<motion.circle cx="90" cy="35" r="5" className="fill-green-500"
initial={{ scale: 0 }} whileInView={{ scale: 1 }} viewport={{ once: true }} transition={{ type: "spring", delay: delay + 1.2 }} />
</svg>
);
export const PromiseSectionIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.path d="M 20 60 L 50 90 L 100 30" stroke="currentColor" strokeWidth="2" className="text-slate-300"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ duration: 1, delay }} />
<motion.path d="M 40 50 H 80 M 40 70 H 70" stroke="currentColor" strokeWidth="1" className="text-slate-100"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ delay: delay + 0.5 }} />
</svg>
);
export const ServicesSectionIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.rect x="20" y="20" width="80" height="80" rx="4" stroke="currentColor" strokeWidth="1" className="text-slate-200"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ duration: 1, delay }} />
<motion.circle cx="60" cy="60" r="20" stroke="currentColor" strokeWidth="1" className="text-slate-300"
initial={{ scale: 0 }} whileInView={{ scale: 1 }} viewport={{ once: true }} transition={{ delay: delay + 0.5 }} />
<motion.path d="M 60 40 V 80 M 40 60 H 80" stroke="currentColor" strokeWidth="1" className="text-slate-100"
initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ delay: delay + 0.8 }} />
</svg>
);
@@ -1,32 +0,0 @@
import * as React from 'react';
import { LucideIcon } from 'lucide-react';
import { Reveal } from '../Reveal';
interface FeatureCardProps {
icon: LucideIcon;
title: string;
description: string;
delay?: number;
}
export const FeatureCard: React.FC<FeatureCardProps> = ({ icon: Icon, title, description, delay = 0 }) => {
return (
<Reveal delay={delay}>
<div className="p-8 md:p-10 border border-slate-100 rounded-2xl hover:border-slate-200 transition-all duration-500 group bg-white hover:shadow-xl hover:shadow-slate-100/50 relative overflow-hidden">
{/* Animated Top Line */}
<div className="absolute top-0 left-0 w-full h-1 bg-slate-900 scale-x-0 group-hover:scale-x-100 transition-transform duration-500 origin-left z-20"></div>
{/* Decorative Ring */}
<div className="absolute top-0 right-0 w-32 h-32 border border-slate-100 rounded-full -mr-16 -mt-16 group-hover:scale-150 transition-transform duration-700"></div>
<Icon className="w-8 h-8 mb-6 text-slate-300 group-hover:text-slate-900 group-hover:scale-110 group-hover:rotate-3 transition-all duration-500 relative z-10" />
<h3 className="text-xl md:text-2xl font-bold text-slate-900 mb-3 tracking-tight relative z-10">
{title}
</h3>
<p className="text-lg md:text-xl text-slate-500 font-serif italic leading-relaxed relative z-10">
{description}
</p>
</div>
</Reveal>
);
};
@@ -1,33 +0,0 @@
import * as React from 'react';
import { Reveal } from '../Reveal';
interface HeroItemProps {
number: string;
title: string;
description: string;
delay?: number;
}
export const HeroItem: React.FC<HeroItemProps> = ({ number, title, description, delay = 0 }) => {
return (
<Reveal delay={delay}>
<div className="group flex gap-6 md:gap-8 p-6 md:p-8 border border-transparent hover:border-slate-100 rounded-2xl transition-all duration-500 hover:bg-slate-50/50 relative overflow-hidden">
{/* Animated Bottom Line */}
<div className="absolute bottom-0 left-8 right-8 h-px bg-slate-200 scale-x-0 group-hover:scale-x-100 transition-transform duration-700 origin-left"></div>
<div className="absolute inset-0 bg-gradient-to-r from-slate-50/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<span className="text-xs font-bold uppercase tracking-[0.2em] text-slate-300 group-hover:text-slate-900 transition-colors pt-1.5 relative z-10">
{number}
</span>
<div className="space-y-2 relative z-10">
<h3 className="text-2xl md:text-4xl font-bold text-slate-900 tracking-tight group-hover:tracking-tighter transition-all duration-500">
{title}
</h3>
<p className="text-lg md:text-xl text-slate-400 font-serif italic leading-snug group-hover:text-slate-600 transition-colors duration-500">
{description}
</p>
</div>
</div>
</Reveal>
);
};
@@ -1,43 +0,0 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { IllustrationProps } from "./types";
export const ConceptAutomation: React.FC<IllustrationProps> = ({
className = "",
delay: _delay = 0,
}) => (
<svg
className={className}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<motion.g
animate={{ rotate: 360 }}
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
style={{ originX: "40px", originY: "60px" }}
>
<path d="M 40 45 L 50 60 L 40 75 L 30 60 Z" className="fill-slate-300" />
</motion.g>
<motion.g
animate={{ rotate: -360 }}
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
style={{ originX: "75px", originY: "65px" }}
>
<path d="M 75 50 L 85 65 L 75 80 L 65 65 Z" className="fill-slate-500" />
</motion.g>
<motion.path
d="M 10 60 H 110"
stroke="currentColor"
strokeWidth="1"
className="text-slate-300"
strokeDasharray="4 4"
animate={{ strokeDashoffset: [0, -20] }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
/>
</svg>
);
@@ -1,33 +0,0 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ConceptCode: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
{[40, 55, 70, 85].map((y, i) => (
<motion.path
key={y}
d={`M 25 ${y} H ${25 + ((i * 17) % 50) + 20}`}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="text-slate-400"
initial={{ pathLength: 0 }}
animate={{ pathLength: [0, 1, 1, 0] }}
transition={{ duration: 4, repeat: Infinity, delay: i * 0.2 + delay }}
/>
))}
<motion.path
d="M 90 40 L 100 50 L 115 30"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className="text-slate-900"
animate={{ opacity: [0, 1, 1, 0], scale: [0.8, 1, 1, 0.8] }}
transition={{ duration: 4, repeat: Infinity, delay: 1.5 }}
/>
</svg>
);
@@ -1,29 +0,0 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ConceptCommunication: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="60" r="6" className="fill-slate-200 stroke-slate-300" strokeWidth="1" />
<circle cx="100" cy="60" r="6" className="fill-slate-900" />
<path d="M 26 60 H 94" stroke="currentColor" strokeWidth="1" className="text-slate-300" strokeDasharray="4 4" />
<motion.path
d="M 26 60 H 94"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
initial={{ pathLength: 0 }}
animate={{ pathLength: [0, 1, 1, 0], opacity: [0, 1, 1, 0] }}
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut", delay }}
/>
<motion.circle r="3" className="fill-slate-900">
<animateMotion
dur="3s"
repeatCount="indefinite"
path="M 26 60 H 94"
/>
</motion.circle>
</svg>
);
@@ -1,37 +0,0 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { IllustrationProps } from "./types";
export const ConceptMessy: React.FC<IllustrationProps> = ({
className = "",
delay: _delay = 0,
}) => (
<svg
className={className}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<motion.path
d="M 20 60 C 30 20, 40 100, 50 60 C 60 20, 70 100, 80 60 C 90 20, 100 100, 110 60"
stroke="currentColor"
strokeWidth="1"
className="text-slate-500"
animate={{ strokeDashoffset: [0, 20] }}
strokeDasharray="4 4"
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
/>
<motion.path
d="M 20 40 L 100 80 M 20 80 L 100 40"
stroke="currentColor"
strokeWidth="1"
className="text-slate-200 opacity-50"
animate={{ opacity: [0.2, 0.5, 0.2] }}
transition={{ duration: 3, repeat: Infinity }}
/>
</svg>
);
@@ -1,27 +0,0 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ConceptPrice: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="30" y="30" width="60" height="70" rx="2" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
<motion.path
d="M 40 50 H 80 M 40 65 H 80 M 40 80 H 60"
stroke="currentColor"
strokeWidth="1"
className="text-slate-300"
initial={{ pathLength: 0 }}
animate={{ pathLength: [0, 1, 1, 0] }}
transition={{ duration: 5, repeat: Infinity, delay }}
/>
<motion.circle
cx="85" cy="35" r="15"
className="fill-white stroke-slate-900"
strokeWidth="1"
animate={{ y: [0, -5, 0], rotate: [0, 10, 0] }}
transition={{ duration: 4, repeat: Infinity }}
/>
</svg>
);
@@ -1,30 +0,0 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ConceptPrototyping: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="15" y="25" width="90" height="70" rx="4" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
<path d="M 15 40 H 105" stroke="currentColor" strokeWidth="1" className="text-slate-300" />
<motion.rect
x="25" y="50" width="40" height="8" rx="1"
className="fill-slate-300"
animate={{ width: [0, 40, 40, 0] }}
transition={{ duration: 4, repeat: Infinity, delay }}
/>
<motion.rect
x="25" y="65" width="60" height="8" rx="1"
className="fill-slate-200"
animate={{ width: [0, 60, 60, 0] }}
transition={{ duration: 4, repeat: Infinity, delay: 0.5 }}
/>
<motion.circle
cx="85" cy="75" r="10"
className="fill-slate-900"
animate={{ scale: [0.8, 1.1, 0.8] }}
transition={{ duration: 2, repeat: Infinity }}
/>
</svg>
);
@@ -1,56 +0,0 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { IllustrationProps } from "./types";
export const ConceptSystem: React.FC<IllustrationProps> = ({
className = "",
delay: _delay = 0,
}) => (
<svg
className={className}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<motion.circle
cx="60"
cy="60"
r="15"
className="fill-slate-900"
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 4, repeat: Infinity }}
/>
{[0, 72, 144, 216, 288].map((angle, i) => {
const x = 60 + Math.cos((angle * Math.PI) / 180) * 40;
const y = 60 + Math.sin((angle * Math.PI) / 180) * 40;
return (
<React.Fragment key={i}>
<motion.line
x1="60"
y1="60"
x2={x}
y2={y}
stroke="currentColor"
strokeWidth="1"
className="text-slate-400"
animate={{ strokeDashoffset: [0, 10] }}
strokeDasharray="2 2"
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
/>
<motion.circle
cx={x}
cy={y}
r="6"
className="fill-white stroke-slate-300"
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 3, repeat: Infinity, delay: i * 0.4 }}
/>
</React.Fragment>
);
})}
</svg>
);
@@ -1,46 +0,0 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { IllustrationProps } from "./types";
export const ConceptTarget: React.FC<IllustrationProps> = ({
className = "",
delay: _delay = 0,
}) => (
<svg
className={className}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<motion.circle
cx="60"
cy="60"
r="50"
stroke="currentColor"
strokeWidth="1"
className="text-slate-300"
animate={{ scale: [1, 1.05, 1] }}
transition={{ duration: 4, repeat: Infinity }}
/>
<motion.circle
cx="60"
cy="60"
r="30"
stroke="currentColor"
strokeWidth="1"
className="text-slate-400"
/>
<motion.circle
cx="60"
cy="60"
r="10"
className="fill-slate-900"
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 2, repeat: Infinity }}
/>
</svg>
);
@@ -1,61 +0,0 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { IllustrationProps } from "./types";
export const ConceptWebsite: React.FC<IllustrationProps> = ({
className = "",
delay: _delay = 0,
}) => (
<svg
className={className}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="10"
y="20"
width="100"
height="80"
rx="4"
stroke="currentColor"
strokeWidth="1"
className="text-slate-400"
/>
<motion.rect
x="20"
y="35"
width="80"
height="15"
rx="2"
className="fill-slate-200"
animate={{ opacity: [0.3, 0.6, 0.3] }}
transition={{ duration: 3, repeat: Infinity }}
/>
<motion.g
animate={{ y: [0, 10, 0] }}
transition={{ duration: 4, repeat: Infinity }}
>
<rect
x="20"
y="55"
width="35"
height="35"
rx="2"
className="fill-slate-300"
/>
<rect
x="65"
y="55"
width="35"
height="35"
rx="2"
className="fill-slate-300"
/>
</motion.g>
</svg>
);
@@ -1,64 +0,0 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ExperienceIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Timeline line */}
<motion.path
d="M 20 100 H 100"
stroke="currentColor"
strokeWidth="1"
className="text-slate-200"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 1.5, delay }}
/>
{/* Experience nodes */}
{[
{ x: 30, y: 80, label: "Agency" },
{ x: 50, y: 60, label: "Corp" },
{ x: 70, y: 40, label: "Startup" },
{ x: 90, y: 20, label: "Now" }
].map((node, i) => (
<React.Fragment key={i}>
<motion.circle
cx={node.x} cy={node.y} r="4"
className={i === 3 ? "fill-slate-900" : "fill-slate-300"}
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 0.5 + i * 0.2, type: "spring" }}
/>
{i > 0 && (
<motion.path
d={`M ${30 + (i-1)*20} ${80 - (i-1)*20} L ${node.x} ${node.y}`}
stroke="currentColor"
strokeWidth="1"
className="text-slate-200"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: delay + 0.5 + (i-1) * 0.2 }}
/>
)}
</React.Fragment>
))}
{/* 15+ Years indicator */}
<motion.text
x="20" y="115"
className="text-[8px] font-bold fill-slate-400 uppercase tracking-widest"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 1.5 }}
>
15+ YEARS
</motion.text>
</svg>
);
@@ -1,58 +0,0 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { IllustrationProps } from "./types";
export const HeroArchitecture: React.FC<IllustrationProps> = ({
className = "",
delay: _delay = 0,
}) => (
<svg
className={className}
viewBox="0 0 400 300"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<motion.rect
x="170"
y="120"
width="60"
height="60"
rx="8"
className="stroke-slate-900 fill-white"
strokeWidth="2"
animate={{ scale: [1, 1.05, 1] }}
transition={{ duration: 4, repeat: Infinity }}
/>
{[
{ x: 80, y: 60 },
{ x: 320, y: 60 },
{ x: 80, y: 240 },
{ x: 320, y: 240 },
].map((node, i) => (
<React.Fragment key={i}>
<motion.path
d={`M 200 150 L ${node.x} ${node.y}`}
stroke="currentColor"
strokeWidth="1"
className="text-slate-400"
animate={{ strokeDashoffset: [0, -10] }}
strokeDasharray="4 4"
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
/>
<motion.circle
cx={node.x}
cy={node.y}
r="12"
className="fill-white stroke-slate-300"
strokeWidth="1"
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 3, repeat: Infinity, delay: i * 0.5 }}
/>
</React.Fragment>
))}
</svg>
);
@@ -1,468 +0,0 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { IllustrationProps } from "./types";
export const HeroMainIllustration: React.FC<IllustrationProps> = ({
className = "",
delay: _delay = 0,
}) => {
return (
<svg
className={className}
viewBox="0 0 800 700"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Matrix-style Binary Rain Background */}
<g className="opacity-[0.08]">
{Array.from({ length: 20 }).map((_, col) => {
const colX = 20 + col * 40;
const speed = 8 + (col % 6);
const startDelay = col % 5;
return (
<motion.g
key={`rain-col-${col}`}
initial={{ y: -700 }}
animate={{ y: 700 }}
transition={{
duration: speed,
repeat: Infinity,
ease: "linear",
delay: startDelay,
}}
>
{Array.from({ length: 25 }).map((_, row) => (
<text
key={`${col}-${row}`}
x={colX}
y={row * 28}
className="fill-slate-900 font-mono"
style={{ fontSize: 12 }}
>
{(col + row) % 2 === 0 ? "1" : "0"}
</text>
))}
</motion.g>
);
})}
</g>
{/* Layer 1: Base Platform */}
<motion.g
animate={{ y: [0, 8, 0] }}
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut" }}
>
<rect
x="150"
y="500"
width="500"
height="30"
rx="4"
className="fill-slate-100 stroke-slate-300"
strokeWidth="1"
/>
<rect
x="170"
y="510"
width="460"
height="10"
rx="2"
className="fill-slate-200"
/>
{/* Binary on base */}
<text
x="180"
y="518"
className="fill-slate-400 font-mono"
style={{ fontSize: 8 }}
>
01010101010101010101010101010101010101
</text>
</motion.g>
{/* Layer 2: Server/Database Layer */}
<motion.g
animate={{ y: [0, 6, 0] }}
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
delay: 0.3,
}}
>
{/* Left Server Block */}
<g transform="translate(200, 400)">
<rect
x="0"
y="0"
width="120"
height="80"
rx="6"
className="fill-white stroke-slate-900"
strokeWidth="2"
/>
<rect
x="10"
y="10"
width="100"
height="15"
rx="2"
className="fill-slate-100"
/>
<rect
x="10"
y="30"
width="80"
height="10"
rx="2"
className="fill-slate-200"
/>
<rect
x="10"
y="45"
width="60"
height="10"
rx="2"
className="fill-slate-200"
/>
<circle cx="100" cy="65" r="5" className="fill-slate-900" />
<text
x="15"
y="20"
className="fill-slate-500 font-mono"
style={{ fontSize: 8 }}
>
SERVER
</text>
</g>
{/* Right Database Block */}
<g transform="translate(480, 400)">
<rect
x="0"
y="0"
width="120"
height="80"
rx="6"
className="fill-white stroke-slate-900"
strokeWidth="2"
/>
<rect
x="10"
y="10"
width="100"
height="15"
rx="2"
className="fill-slate-100"
/>
<rect
x="10"
y="30"
width="100"
height="8"
rx="2"
className="fill-slate-200"
/>
<rect
x="10"
y="42"
width="100"
height="8"
rx="2"
className="fill-slate-200"
/>
<rect
x="10"
y="54"
width="100"
height="8"
rx="2"
className="fill-slate-200"
/>
<text
x="15"
y="20"
className="fill-slate-500 font-mono"
style={{ fontSize: 8 }}
>
DATABASE
</text>
</g>
{/* Connection Lines */}
<motion.path
d="M 320 440 L 400 440 L 480 440"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
strokeDasharray="6 4"
animate={{ strokeDashoffset: [0, -20] }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
/>
</motion.g>
{/* Layer 3: Browser/Website */}
<motion.g
animate={{ y: [0, 4, 0] }}
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
delay: 0.6,
}}
>
{/* Browser Window */}
<rect
x="180"
y="100"
width="440"
height="280"
rx="8"
className="fill-white stroke-slate-900"
strokeWidth="2"
/>
{/* Browser Chrome */}
<rect
x="180"
y="100"
width="440"
height="30"
rx="8"
className="fill-slate-900"
/>
<rect
x="180"
y="120"
width="440"
height="10"
className="fill-slate-900"
/>
{/* Browser Dots */}
<circle cx="200" cy="115" r="5" className="fill-slate-600" />
<circle cx="218" cy="115" r="5" className="fill-slate-600" />
<circle cx="236" cy="115" r="5" className="fill-slate-600" />
{/* Address Bar */}
<rect
x="260"
y="108"
width="200"
height="14"
rx="3"
className="fill-slate-700"
/>
{/* Website Content */}
<g transform="translate(200, 150)">
{/* Navigation */}
<rect x="0" y="0" width="400" height="20" className="fill-slate-50" />
<rect
x="10"
y="5"
width="60"
height="10"
rx="2"
className="fill-slate-900"
/>
<rect
x="280"
y="5"
width="30"
height="10"
rx="2"
className="fill-slate-300"
/>
<rect
x="320"
y="5"
width="30"
height="10"
rx="2"
className="fill-slate-300"
/>
<rect
x="360"
y="5"
width="30"
height="10"
rx="2"
className="fill-slate-300"
/>
{/* Hero Section */}
<rect
x="0"
y="30"
width="400"
height="100"
className="fill-slate-100"
/>
<rect
x="20"
y="50"
width="180"
height="16"
rx="2"
className="fill-slate-900"
/>
<rect
x="20"
y="72"
width="140"
height="10"
rx="2"
className="fill-slate-400"
/>
<rect
x="20"
y="88"
width="100"
height="10"
rx="2"
className="fill-slate-400"
/>
<rect
x="20"
y="108"
width="80"
height="16"
rx="4"
className="fill-slate-900"
/>
{/* Hero Image Placeholder */}
<rect
x="240"
y="40"
width="140"
height="80"
rx="4"
className="fill-slate-200"
/>
<path
d="M 280 80 L 310 60 L 340 80 L 310 100 Z"
className="fill-slate-300"
/>
{/* Cards Section */}
<g transform="translate(0, 140)">
<rect
x="0"
y="0"
width="125"
height="70"
rx="4"
className="fill-slate-50 stroke-slate-200"
strokeWidth="1"
/>
<rect
x="10"
y="10"
width="105"
height="30"
rx="2"
className="fill-slate-200"
/>
<rect
x="10"
y="48"
width="80"
height="8"
rx="2"
className="fill-slate-300"
/>
<rect
x="137"
y="0"
width="125"
height="70"
rx="4"
className="fill-slate-50 stroke-slate-200"
strokeWidth="1"
/>
<rect
x="147"
y="10"
width="105"
height="30"
rx="2"
className="fill-slate-200"
/>
<rect
x="147"
y="48"
width="80"
height="8"
rx="2"
className="fill-slate-300"
/>
<rect
x="274"
y="0"
width="125"
height="70"
rx="4"
className="fill-slate-50 stroke-slate-200"
strokeWidth="1"
/>
<rect
x="284"
y="10"
width="105"
height="30"
rx="2"
className="fill-slate-200"
/>
<rect
x="284"
y="48"
width="80"
height="8"
rx="2"
className="fill-slate-300"
/>
</g>
</g>
</motion.g>
{/* Connecting Lines from Browser to Infrastructure */}
<motion.g>
<motion.path
d="M 400 380 L 400 400"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
strokeDasharray="4 4"
animate={{ strokeDashoffset: [0, -16] }}
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
/>
<motion.path
d="M 260 480 L 260 500"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
strokeDasharray="4 4"
animate={{ strokeDashoffset: [0, -16] }}
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
/>
<motion.path
d="M 540 480 L 540 500"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
strokeDasharray="4 4"
animate={{ strokeDashoffset: [0, -16] }}
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
/>
</motion.g>
</svg>
);
};
@@ -1,52 +0,0 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ResponsibilityIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Shield / Responsibility shape */}
<motion.path
d="M 60 20 L 90 35 V 65 C 90 85 60 100 60 100 C 60 100 30 85 30 65 V 35 L 60 20 Z"
stroke="currentColor"
strokeWidth="1"
className="text-slate-900"
initial={{ pathLength: 0, fill: "rgba(15, 23, 42, 0)" }}
whileInView={{ pathLength: 1, fill: "rgba(15, 23, 42, 0.05)" }}
viewport={{ once: true }}
transition={{ duration: 1.5, delay }}
/>
{/* Core point */}
<motion.circle
cx="60" cy="55" r="8"
className="fill-slate-900"
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 1, type: "spring" }}
/>
{/* Responsibility lines */}
{[0, 120, 240].map((angle, i) => {
const x1 = 60 + Math.cos((angle * Math.PI) / 180) * 12;
const y1 = 55 + Math.sin((angle * Math.PI) / 180) * 12;
const x2 = 60 + Math.cos((angle * Math.PI) / 180) * 30;
const y2 = 55 + Math.sin((angle * Math.PI) / 180) * 30;
return (
<motion.line
key={i}
x1={x1} y1={y1} x2={x2} y2={y2}
stroke="currentColor"
strokeWidth="1"
className="text-slate-400"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 1.2 + i * 0.2 }}
/>
);
})}
</svg>
);
@@ -1,54 +0,0 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ResultIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Result Box */}
<motion.rect
x="30" y="30" width="60" height="60" rx="4"
stroke="currentColor"
strokeWidth="1"
className="text-slate-900"
initial={{ pathLength: 0, rotate: -10, opacity: 0 }}
whileInView={{ pathLength: 1, rotate: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 1, delay }}
/>
{/* Checkmark */}
<motion.path
d="M 45 60 L 55 70 L 75 50"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-slate-900"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: delay + 1 }}
/>
{/* Sparkles */}
{[
{ x: 25, y: 25 },
{ x: 95, y: 35 },
{ x: 85, y: 95 }
].map((pos, i) => (
<motion.path
key={i}
d={`M ${pos.x} ${pos.y-4} V ${pos.y+4} M ${pos.x-4} ${pos.y} H ${pos.x+4}`}
stroke="currentColor"
strokeWidth="1"
className="text-slate-300"
initial={{ scale: 0, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 1.5 + i * 0.2, type: "spring" }}
/>
))}
</svg>
);
@@ -1,333 +0,0 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const SystemArchitecture: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Central Core */}
<motion.rect
x="70" y="70" width="60" height="60" rx="12"
className="fill-slate-900"
initial={{ scale: 0, rotate: -45 }}
whileInView={{ scale: 1, rotate: 0 }}
viewport={{ once: true }}
transition={{ type: "spring", stiffness: 100, delay }}
/>
{/* Orbiting Elements */}
{[0, 90, 180, 270].map((angle, i) => {
const rad = (angle * Math.PI) / 180;
const x = 100 + Math.cos(rad) * 60 - 15;
const y = 100 + Math.sin(rad) * 60 - 15;
return (
<motion.rect
key={i}
x={x} y={y} width="30" height="30" rx="8"
className="fill-white stroke-slate-200"
strokeWidth="1"
initial={{ opacity: 0, scale: 0 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
animate={{
y: [y, y - 5, y],
}}
transition={{
delay: delay + 0.2 + i * 0.1,
duration: 3,
repeat: Infinity,
repeatDelay: i * 0.5,
ease: "easeInOut"
}}
/>
);
})}
{/* Connection Lines */}
<motion.path
d="M 100 40 V 70 M 100 130 V 160 M 40 100 H 70 M 130 100 H 160"
stroke="currentColor" strokeWidth="1"
className="text-slate-200"
strokeDasharray="4 4"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 1, delay: delay + 0.6 }}
/>
</svg>
);
export const SpeedPerformance: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* The "Built" Foundation */}
<motion.path
d="M 40 160 H 160"
stroke="currentColor" strokeWidth="4" strokeLinecap="round"
className="text-slate-900"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay }}
/>
{/* Bricks forming a structure */}
{[
{ x: 50, y: 130, w: 30 },
{ x: 85, y: 130, w: 30 },
{ x: 120, y: 130, w: 30 },
{ x: 65, y: 105, w: 30 },
{ x: 105, y: 105, w: 30 },
].map((brick, i) => (
<motion.rect
key={i}
x={brick.x} y={brick.y} width={brick.w} height="20" rx="2"
className="fill-slate-100 stroke-slate-200"
strokeWidth="1"
initial={{ opacity: 0, y: brick.y + 10 }}
whileInView={{ opacity: 1, y: brick.y }}
viewport={{ once: true }}
transition={{ delay: delay + 0.2 + i * 0.1 }}
/>
))}
{/* Lightning Bolt emerging from the "Built" structure */}
<motion.path
d="M 110 30 L 80 80 H 100 L 70 130"
stroke="currentColor" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round"
className="text-slate-900"
initial={{ pathLength: 0, filter: "drop-shadow(0 0 0px rgba(0,0,0,0))" }}
whileInView={{ pathLength: 1, filter: "drop-shadow(0 0 8px rgba(0,0,0,0.1))" }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: delay + 1, ease: "easeOut" }}
/>
{/* Speed Lines */}
{[0, 1, 2].map((i) => (
<motion.path
key={i}
d={`M ${130 + i * 10} ${50 + i * 20} H ${150 + i * 10}`}
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
className="text-slate-200"
initial={{ x: -10, opacity: 0 }}
animate={{ x: 10, opacity: [0, 1, 0] }}
transition={{ duration: 0.8, repeat: Infinity, delay: delay + 1.2 + i * 0.2 }}
/>
))}
</svg>
);
export const SolidFoundation: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Stacked Blocks */}
<motion.rect
x="40" y="140" width="120" height="30" rx="4"
className="fill-slate-900"
initial={{ y: 20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ delay }}
/>
<motion.rect
x="60" y="100" width="80" height="30" rx="4"
className="fill-slate-400"
initial={{ y: 20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 0.2 }}
/>
<motion.rect
x="80" y="60" width="40" height="30" rx="4"
className="fill-slate-200"
initial={{ y: 20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 0.4 }}
/>
{/* Shield Icon Overlay */}
<motion.path
d="M 100 30 L 120 40 V 60 C 120 80 100 90 100 90 C 100 90 80 80 80 60 V 40 L 100 30 Z"
className="fill-white stroke-slate-900"
strokeWidth="2"
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true }}
transition={{ type: "spring", delay: delay + 0.8 }}
/>
</svg>
);
export const LayerSeparation: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Top Layer (Content) */}
<motion.g
initial={{ y: -20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ delay }}
>
<rect x="40" y="40" width="120" height="40" rx="8" className="fill-white stroke-slate-200" strokeWidth="1" />
<rect x="55" y="55" width="30" height="10" rx="2" className="fill-slate-100" />
<rect x="95" y="55" width="50" height="10" rx="2" className="fill-slate-100" />
</motion.g>
{/* Gap with Arrows */}
<motion.path
d="M 100 90 V 110"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
className="text-slate-200"
strokeDasharray="4 4"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 0.4 }}
/>
{/* Bottom Layer (Code/Logic) */}
<motion.g
initial={{ y: 20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 0.2 }}
>
<rect x="40" y="120" width="120" height="40" rx="8" className="fill-slate-900" />
<path d="M 60 140 L 70 135 L 60 130 M 140 140 L 130 135 L 140 130" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</motion.g>
</svg>
);
export const DirectService: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Two Nodes */}
<motion.circle
cx="50" cy="100" r="15"
className="fill-slate-200"
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true }}
transition={{ delay }}
/>
<motion.circle
cx="150" cy="100" r="15"
className="fill-slate-900"
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 0.2 }}
/>
{/* Direct Connection */}
<motion.path
d="M 65 100 H 135"
stroke="currentColor" strokeWidth="3" strokeLinecap="round"
className="text-slate-900"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: delay + 0.4 }}
/>
{/* Pulse moving between */}
<motion.circle r="4" className="fill-white">
<animateMotion
dur="2s"
repeatCount="indefinite"
path="M 65 100 H 135"
/>
</motion.circle>
</svg>
);
export const AgencyChaos: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Messy Path */}
<motion.path
d="M 30 100 C 50 40, 70 160, 90 100 C 110 40, 130 160, 170 100"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
className="text-slate-200"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 2, delay }}
/>
{/* Intersecting Circles (Meetings) */}
{[0, 1, 2].map((i) => (
<motion.circle
key={i}
cx={60 + i * 40} cy={100 + (i % 2 === 0 ? -30 : 30)} r="20"
className="stroke-slate-100 fill-white"
strokeWidth="1"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
animate={{ scale: [1, 1.1, 1] }}
transition={{
delay: delay + 0.5 + i * 0.3,
duration: 4,
repeat: Infinity,
repeatDelay: i * 0.5
}}
/>
))}
{/* Slate "X" or Stop signs - NO COLOR */}
<motion.path
d="M 160 90 L 180 110 M 180 90 L 160 110"
stroke="currentColor" strokeWidth="3" strokeLinecap="round"
className="text-slate-300"
initial={{ opacity: 0, scale: 0 }}
whileInView={{ opacity: 0.5, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 1.5 }}
/>
</svg>
);
export const TaskDone: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Checkmark Circle */}
<motion.circle
cx="100" cy="100" r="60"
className="stroke-slate-900"
strokeWidth="2"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 1, delay }}
/>
{/* Checkmark */}
<motion.path
d="M 70 100 L 90 120 L 135 75"
stroke="currentColor" strokeWidth="8" strokeLinecap="round" strokeLinejoin="round"
className="text-slate-900"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: delay + 0.8 }}
/>
{/* Confetti/Sparkles */}
{[0, 45, 90, 135, 180, 225, 270, 315].map((angle, i) => {
const rad = (angle * Math.PI) / 180;
const x1 = 100 + Math.cos(rad) * 70;
const y1 = 100 + Math.sin(rad) * 70;
const x2 = 100 + Math.cos(rad) * 85;
const y2 = 100 + Math.sin(rad) * 85;
return (
<motion.line
key={i}
x1={x1} y1={y1} x2={x2} y2={y2}
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
className="text-slate-200"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: delay + 1.2 + i * 0.05 }}
/>
);
})}
</svg>
);
@@ -1,16 +0,0 @@
export * from './types';
export * from './ConceptCommunication';
export * from './ConceptPrototyping';
export * from './ConceptCode';
export * from './ConceptPrice';
export * from './ConceptWebsite';
export * from './ConceptSystem';
export * from './ConceptAutomation';
export * from './ConceptTarget';
export * from './ConceptMessy';
export * from './HeroArchitecture';
export * from './HeroMainIllustration';
export * from './ExperienceIllustration';
export * from './ResponsibilityIllustration';
export * from './ResultIllustration';
export * from './WebsitesDescriptive';
@@ -1,10 +0,0 @@
"use client";
/* eslint-disable @typescript-eslint/no-unused-vars */
import * as React from "react";
export interface IllustrationProps {
className?: string;
delay?: number;
}
@@ -1,259 +0,0 @@
'use client';
import * as React from 'react';
import { useEffect, useRef, useCallback } from 'react';
interface Particle {
x: number;
y: number;
baseX: number; // The "home" x position (in the gutters)
baseY: number;
vx: number;
vy: number;
size: number;
alpha: number;
targetAlpha: number;
}
interface ParticleNetworkProps {
className?: string;
}
export const ParticleNetwork: React.FC<ParticleNetworkProps> = ({ className = '' }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const particlesRef = useRef<Particle[]>([]);
const animationFrameRef = useRef<number>(0);
const scrollRef = useRef<number>(0);
const mouseRef = useRef<{ x: number; y: number }>({ x: -1000, y: -1000 });
const dimensionsRef = useRef<{ width: number; height: number }>({ width: 0, height: 0 });
// Configuration
const config = {
particleCount: 60, // Reduced count for cleaner look
connectionDistance: 180,
mouseRadius: 250,
baseSpeed: 0.2,
gutterWidth: 0.25, // 25% of width on each side
colors: {
particle: 'rgba(148, 163, 184, 0.8)', // slate-400
line: 'rgba(203, 213, 225, 0.4)', // slate-300
},
};
// Initialize particles
const initParticles = useCallback((width: number, height: number) => {
const particles: Particle[] = [];
const count = config.particleCount;
// Create particles primarily in the side gutters
for (let i = 0; i < count; i++) {
const isLeft = Math.random() > 0.5;
// Random position within the gutter (0-25% or 75-100%)
const gutterX = isLeft
? Math.random() * (width * config.gutterWidth)
: width - (Math.random() * (width * config.gutterWidth));
// Add some occasional strays near the content but not IN it
const x = gutterX;
const y = Math.random() * height;
particles.push({
x,
y,
baseX: x,
baseY: y,
vx: (Math.random() - 0.5) * 0.1, // Very slight horizontal drift
vy: 0.2 + Math.random() * 0.3, // Consistent downward flow
size: Math.random() * 1.5 + 0.5,
alpha: 0,
targetAlpha: Math.random() * 0.6 + 0.2,
});
}
particlesRef.current = particles;
}, [config.particleCount, config.gutterWidth]);
// Animation loop
const animate = useCallback(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
const { width, height } = dimensionsRef.current;
const scroll = scrollRef.current;
const mouse = mouseRef.current;
const particles = particlesRef.current;
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Update and draw particles
particles.forEach((p, _i) => {
// 1. Movement
// Apply downward flow
p.y += p.vy;
p.x += p.vx;
// Wrap around vertical
if (p.y > height) {
p.y = -10;
p.x = p.baseX; // Reset X to base to keep structure
}
// 2. Mouse Interaction (Repulsion)
const dx = p.x - mouse.x;
const dy = p.y - mouse.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < config.mouseRadius) {
const force = (config.mouseRadius - dist) / config.mouseRadius;
const angle = Math.atan2(dy, dx);
const pushX = Math.cos(angle) * force * 2;
const pushY = Math.sin(angle) * force * 2;
p.x += pushX;
p.y += pushY;
}
// 3. Return to "Lane" (Elasticity)
// Gently pull x back towards baseX if not influenced by mouse
if (dist >= config.mouseRadius) {
p.x += (p.baseX - p.x) * 0.02;
}
// 4. Scroll Effect (Parallax/Speed)
// Scroll adds a temporary velocity boost or shift
// We use a simple factor here, but could be more complex
const scrollFactor = Math.max(0, Math.min(1, scroll * 0.001));
p.y += scrollFactor * 0.5; // Move faster when scrolled down?
// Actually, let's make them react to scroll speed if we tracked delta,
// but for now just position shift based on scroll is handled by the container being fixed.
// Let's add a subtle wave based on scroll position
p.x += Math.sin(scroll * 0.002 + p.y * 0.01) * 0.2;
// Fade in
if (p.alpha < p.targetAlpha) p.alpha += 0.01;
// Draw Particle
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(148, 163, 184, ${p.alpha})`;
ctx.fill();
});
// Draw Connections
// We only connect particles that are close enough
ctx.lineWidth = 0.5;
for (let i = 0; i < particles.length; i++) {
const p1 = particles[i];
// Optimization: only check particles after this one
for (let j = i + 1; j < particles.length; j++) {
const p2 = particles[j];
// Quick check for distance
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
// Optimization: skip sqrt if obviously too far
if (Math.abs(dx) > config.connectionDistance || Math.abs(dy) > config.connectionDistance) continue;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < config.connectionDistance) {
// Calculate opacity based on distance
const opacity = (1 - dist / config.connectionDistance) * 0.3;
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = `rgba(203, 213, 225, ${opacity})`;
ctx.stroke();
}
}
}
animationFrameRef.current = requestAnimationFrame(animate);
}, [config]);
// Handle resize
const handleResize = useCallback(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.scale(dpr, dpr);
}
dimensionsRef.current = { width: rect.width, height: rect.height };
initParticles(rect.width, rect.height);
}, [initParticles]);
// Handle scroll
const handleScroll = useCallback(() => {
scrollRef.current = window.scrollY;
}, []);
// Handle mouse move
const handleMouseMove = useCallback((e: MouseEvent) => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
mouseRef.current = {
x: e.clientX - rect.left,
y: e.clientY - rect.top, // Fixed position, so no scrollY needed for mouse relative to canvas
};
}, []);
// Handle mouse leave
const handleMouseLeave = useCallback(() => {
mouseRef.current = { x: -1000, y: -1000 };
}, []);
useEffect(() => {
handleResize();
handleScroll();
window.addEventListener('resize', handleResize);
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('mousemove', handleMouseMove, { passive: true });
window.addEventListener('mouseleave', handleMouseLeave);
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseleave', handleMouseLeave);
cancelAnimationFrame(animationFrameRef.current);
};
}, [handleResize, handleScroll, handleMouseMove, handleMouseLeave, animate]);
return (
<div
ref={containerRef}
className={`fixed inset-0 pointer-events-none -z-10 ${className}`}
aria-hidden="true"
>
<canvas
ref={canvasRef}
className="w-full h-full"
/>
</div>
);
};
export default ParticleNetwork;
@@ -1,50 +0,0 @@
import * as React from 'react';
import Link from 'next/link';
import { Reveal } from '../Reveal';
interface ServiceCardProps {
title: string;
description: string;
linkHref?: string;
linkLabel?: string;
delay?: number;
}
export const ServiceCard: React.FC<ServiceCardProps> = ({
title,
description,
linkHref,
linkLabel,
delay = 0,
}) => {
return (
<Reveal delay={delay}>
<div className="group relative p-10 md:p-12 border border-slate-100 rounded-3xl hover:border-slate-900 transition-all duration-700 bg-white hover:shadow-2xl hover:shadow-slate-100/50 overflow-hidden">
{/* Decorative Corner */}
<div className="absolute top-0 right-0 w-32 h-32 border-t border-r border-slate-100 rounded-tr-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-700"></div>
<div className="absolute top-8 right-8 w-2 h-2 bg-slate-900 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-700 delay-100"></div>
{/* Decorative background text */}
<div className="absolute -right-4 -bottom-4 text-8xl md:text-9xl font-bold text-slate-50 select-none group-hover:text-slate-100 transition-colors duration-700 -z-10">
{title.charAt(0)}
</div>
<h3 className="text-4xl md:text-6xl font-bold text-slate-900 mb-6 tracking-tighter group-hover:tracking-tight transition-all duration-700">
{title}
</h3>
<p className="text-xl md:text-2xl text-slate-500 font-serif italic mb-10 leading-tight max-w-3xl group-hover:text-slate-700 transition-colors duration-700">
{description}
</p>
{linkHref && linkLabel && (
<Link
href={linkHref}
className="inline-flex items-center gap-4 text-slate-900 font-bold text-[10px] uppercase tracking-[0.2em] group/link"
>
{linkLabel}
<div className="w-8 h-px bg-slate-200 group-hover/link:bg-slate-900 group-hover/link:w-16 transition-all duration-500"></div>
</Link>
)}
</div>
</Reveal>
);
};
-10
View File
@@ -1,10 +0,0 @@
export * from './AbstractLines';
export * from './ExplanatoryIllustrations';
export * from './ConceptIllustrations';
export * from './Illustrations';
export * from './ComparisonRow';
export * from './FeatureCard';
export * from './HeroItem';
export * from './ParticleNetwork';
export * from './ServiceCard';
-116
View File
@@ -1,116 +0,0 @@
"use client";
import * as React from "react";
import { cn } from "../utils/cn";
export const BackgroundGrid: React.FC = () => (
<div className="fixed inset-0 pointer-events-none -z-20" aria-hidden="true">
<div
className="absolute inset-0 opacity-[0.015]"
style={{
backgroundImage: `
linear-gradient(#94a3b8 1px, transparent 1px),
linear-gradient(90deg, #94a3b8 1px, transparent 1px)
`,
backgroundSize: "80px 80px",
}}
/>
</div>
);
interface CardProps {
children: React.ReactNode;
className?: string;
variant?: "white" | "dark" | "gray" | "glass";
hover?: boolean;
padding?: "none" | "small" | "normal" | "large";
techBorder?: boolean;
}
export const Card: React.FC<CardProps> = ({
children,
className = "",
variant = "white",
hover = true,
padding = "normal",
techBorder = false,
}) => {
const variants = {
white: "bg-white border-slate-100 text-slate-900 shadow-sm",
dark: "bg-slate-900 border-white/5 text-white shadow-xl",
gray: "bg-slate-50/50 border-slate-100 text-slate-900",
glass: "glass text-slate-900",
};
const paddings = {
none: "p-0",
small: "p-4 md:p-8",
normal: "p-5 md:p-10",
large: "p-6 md:p-12",
};
const [hexId, setHexId] = React.useState("0x0000");
React.useEffect(() => {
setHexId(
`0x${Math.floor(Math.random() * 0xffff)
.toString(16)
.padStart(4, "0")
.toUpperCase()}`,
);
}, []);
return (
<div
className={cn(
"rounded-2xl border h-full flex flex-col justify-between transition-all duration-500 ease-out relative overflow-hidden group/card",
variants[variant],
paddings[padding],
hover ? "hover:border-slate-200 hover:shadow-md" : "",
techBorder ? "tech-border" : "",
className,
)}
>
{/* Binary corner accent on hover */}
{hover && (
<span
className="absolute top-3 right-3 text-[7px] font-mono tracking-widest opacity-0 group-hover/card:opacity-100 transition-opacity duration-500 select-none pointer-events-none"
style={{
color:
variant === "dark"
? "rgba(255,255,255,0.1)"
: "rgba(148,163,184,0.2)",
}}
aria-hidden="true"
>
{hexId}
</span>
)}
{children}
</div>
);
};
export const Container: React.FC<{
children: React.ReactNode;
className?: string;
variant?: "narrow" | "normal" | "wide";
}> = ({ children, className = "", variant = "normal" }) => {
const variants = {
narrow: "max-w-4xl",
normal: "max-w-6xl",
wide: "max-w-7xl",
};
return (
<div
className={cn(
"mx-auto px-5 md:px-6 w-full",
variants[variant],
className,
)}
>
{children}
</div>
);
};
-64
View File
@@ -1,64 +0,0 @@
"use client";
import React from "react";
import { Button } from "./Button";
import { Reveal } from "./Reveal";
import { ShieldCheck, Zap } from "lucide-react";
interface LeadMagnetProps {
title: string;
description: string;
buttonText: string;
href: string;
variant?: "performance" | "security" | "standard";
}
/**
* LeadMagnet: A high-fidelity conversion card for blog posts.
* Replaces simple buttons with a structured, trust-building CTA block.
*/
export const LeadMagnet: React.FC<LeadMagnetProps> = ({
title,
description,
buttonText,
href,
variant = "standard",
}) => {
return (
<Reveal direction="up">
<div className="my-16 not-prose">
<div className="relative p-8 md:p-12 rounded-[2rem] overflow-hidden border border-slate-200 bg-white shadow-2xl shadow-slate-200/50 group transition-all duration-500 hover:shadow-slate-300/50">
{/* Decorative background visual */}
<div className="absolute top-0 right-0 w-64 h-64 bg-slate-50 rounded-full blur-3xl -mr-32 -mt-32 opacity-50 group-hover:opacity-100 transition-opacity duration-1000" />
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-8">
<div className="max-w-xl space-y-4">
<div className="flex items-center gap-2 text-slate-400 uppercase tracking-widest text-[10px] font-bold">
{variant === "performance" ? <Zap className="w-3 h-3 text-emerald-500" /> : <ShieldCheck className="w-3 h-3 text-blue-500" />}
<span>B2B Performance Insight</span>
</div>
<h3 className="text-3xl md:text-4xl font-black text-slate-900 tracking-tight leading-[0.9]">
{title}
</h3>
<p className="text-sm md:text-base text-slate-500 leading-relaxed font-medium max-w-md">
{description}
</p>
</div>
<div className="flex flex-col items-center gap-4 min-w-[240px]">
<Button href={href} size="large" className="w-full">
{buttonText}
</Button>
<div className="flex items-center gap-1.5 text-slate-400 text-[10px] font-mono">
<ShieldCheck className="w-3 h-3" />
<span>100% DSGVO-konform & Unverbindlich</span>
</div>
</div>
</div>
</div>
</div>
</Reveal>
);
};
@@ -1,6 +0,0 @@
import * as React from "react";
/** Keeps pre-relaunch pages (blog, legal, ...) legible on the dark site shell. */
export const LegacyLightSurface: React.FC<{ children: React.ReactNode }> = ({
children,
}) => <div className="theme-light min-h-[60vh]">{children}</div>;

Some files were not shown because too many files have changed in this diff Show More