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:
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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">→</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>
|
||||
);
|
||||
};
|
||||
@@ -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" />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">< 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">
|
||||
© ${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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user