chore: overhaul infrastructure and integrate @mintel packages
Some checks failed
🧪 CI (QA) / 🧪 Quality Assurance (push) Failing after 1m3s
Some checks failed
🧪 CI (QA) / 🧪 Quality Assurance (push) Failing after 1m3s
- Restructure to pnpm monorepo (site moved to apps/web) - Integrate @mintel/tsconfig, @mintel/eslint-config, @mintel/husky-config - Implement Docker service architecture (Varnish, Directus, Gatekeeper) - Setup environment-aware Gitea Actions deployment
This commit is contained in:
154
apps/web/src/components/AgbsPDF.tsx
Normal file
154
apps/web/src/components/AgbsPDF.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'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 } from './pdf/SharedUI';
|
||||
import { SimpleLayout } from './pdf/SimpleLayout';
|
||||
|
||||
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 {
|
||||
state: any;
|
||||
headerIcon?: string;
|
||||
footerLogo?: string;
|
||||
mode?: 'estimation' | 'full';
|
||||
}
|
||||
|
||||
export const AgbsPDF = ({ state, headerIcon, 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} icon={headerIcon} pageNumber="10" showPageNumber={false}>
|
||||
{content}
|
||||
</SimpleLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PDFPage size="A4" style={pdfStyles.page}>
|
||||
<FoldingMarks />
|
||||
<Header icon={headerIcon} showAddress={false} />
|
||||
{content}
|
||||
<Footer logo={footerLogo} companyData={companyData} bankData={bankData} showDetails={false} showPageNumber={false} />
|
||||
</PDFPage>
|
||||
);
|
||||
};
|
||||
89
apps/web/src/components/Analytics.tsx
Normal file
89
apps/web/src/components/Analytics.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { getDefaultAnalytics } from '../utils/analytics';
|
||||
import { getDefaultErrorTracking } from '../utils/error-tracking';
|
||||
|
||||
export const Analytics: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const analytics = getDefaultAnalytics();
|
||||
const errorTracking = getDefaultErrorTracking();
|
||||
|
||||
// Track page load performance
|
||||
const trackPageLoad = () => {
|
||||
const perfData = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||
if (perfData && typeof perfData.loadEventEnd === 'number' && typeof perfData.startTime === 'number') {
|
||||
const loadTime = perfData.loadEventEnd - perfData.startTime;
|
||||
analytics.trackPageLoad(
|
||||
loadTime,
|
||||
window.location.pathname,
|
||||
navigator.userAgent
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Track outbound links
|
||||
const trackOutboundLinks = () => {
|
||||
document.querySelectorAll('a[href^="http"]').forEach(link => {
|
||||
const anchor = link as HTMLAnchorElement;
|
||||
if (!anchor.href.includes(window.location.hostname)) {
|
||||
anchor.addEventListener('click', () => {
|
||||
analytics.trackOutboundLink(anchor.href, anchor.textContent?.trim() || 'unknown');
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Track search
|
||||
const trackSearch = () => {
|
||||
const searchInput = document.querySelector('input[type="search"]') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
const handleSearch = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.value) {
|
||||
analytics.trackSearch(target.value, window.location.pathname);
|
||||
}
|
||||
};
|
||||
searchInput.addEventListener('search', handleSearch);
|
||||
return () => searchInput.removeEventListener('search', handleSearch);
|
||||
}
|
||||
};
|
||||
|
||||
// Global error handler for error tracking
|
||||
const handleGlobalError = (event: ErrorEvent) => {
|
||||
errorTracking.captureException(event.error || event.message);
|
||||
};
|
||||
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
errorTracking.captureException(event.reason);
|
||||
};
|
||||
|
||||
window.addEventListener('error', handleGlobalError);
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
|
||||
trackPageLoad();
|
||||
trackOutboundLinks();
|
||||
const cleanupSearch = trackSearch();
|
||||
|
||||
return () => {
|
||||
if (cleanupSearch) cleanupSearch();
|
||||
window.removeEventListener('error', handleGlobalError);
|
||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const analytics = getDefaultAnalytics();
|
||||
const adapter = analytics.getAdapter();
|
||||
const scriptTag = adapter.getScriptTag ? adapter.getScriptTag() : null;
|
||||
|
||||
if (!scriptTag) return null;
|
||||
|
||||
// We use dangerouslySetInnerHTML to inject the script tag from the adapter
|
||||
// This is safe here because the script URLs and IDs come from our own config/env
|
||||
return (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: scriptTag }}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
208
apps/web/src/components/ArticleBlockquote.tsx
Normal file
208
apps/web/src/components/ArticleBlockquote.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
import 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';
|
||||
|
||||
interface BlockquoteProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Blockquote: React.FC<BlockquoteProps> = ({ children, className = '' }) => (
|
||||
<blockquote className={`border-l-4 border-slate-900 pl-6 italic text-slate-700 my-8 text-xl md:text-2xl font-serif ${className}`}>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
|
||||
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={`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={`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;
|
||||
}
|
||||
`;
|
||||
24
apps/web/src/components/ArticleHeading.tsx
Normal file
24
apps/web/src/components/ArticleHeading.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
interface HeadingProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const H1: React.FC<HeadingProps> = ({ children, className = '' }) => (
|
||||
<h1 className={`text-4xl md:text-5xl font-bold text-slate-900 mb-8 mt-12 leading-[1.1] tracking-tight ${className}`}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
|
||||
export const H2: React.FC<HeadingProps> = ({ children, className = '' }) => (
|
||||
<h2 className={`text-3xl md:text-4xl font-bold text-slate-900 mb-6 mt-10 leading-[1.2] tracking-tight ${className}`}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
|
||||
export const H3: React.FC<HeadingProps> = ({ children, className = '' }) => (
|
||||
<h3 className={`text-2xl md:text-3xl font-bold text-slate-900 mb-4 mt-8 leading-[1.3] tracking-tight ${className}`}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
24
apps/web/src/components/ArticleList.tsx
Normal file
24
apps/web/src/components/ArticleList.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
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>
|
||||
);
|
||||
18
apps/web/src/components/ArticleParagraph.tsx
Normal file
18
apps/web/src/components/ArticleParagraph.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ParagraphProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Paragraph: React.FC<ParagraphProps> = ({ children, className = '' }) => (
|
||||
<p className={`text-slate-700 font-serif text-lg md:text-xl leading-relaxed mb-6 ${className}`}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
export const LeadParagraph: React.FC<ParagraphProps> = ({ children, className = '' }) => (
|
||||
<p className={`text-xl md:text-2xl text-slate-700 font-serif italic leading-relaxed mb-8 ${className}`}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
131
apps/web/src/components/BlogPostClient.tsx
Normal file
131
apps/web/src/components/BlogPostClient.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface BlogPostClientProps {
|
||||
readingTime: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const BlogPostClient: React.FC<BlogPostClientProps> = ({ readingTime, title }) => {
|
||||
const router = useRouter();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 100);
|
||||
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
const handleBack = () => {
|
||||
// Lovely exit animation
|
||||
const content = document.getElementById('post-content');
|
||||
if (content) {
|
||||
content.style.transition = 'opacity 0.5s ease-out, transform 0.5s ease-out';
|
||||
content.style.opacity = '0';
|
||||
content.style.transform = 'translateY(20px) scale(0.98)';
|
||||
}
|
||||
|
||||
const topNav = document.getElementById('top-nav');
|
||||
if (topNav) {
|
||||
topNav.style.transition = 'opacity 0.4s ease-out';
|
||||
topNav.style.opacity = '0';
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'fixed inset-0 bg-gradient-to-br from-white via-slate-50 to-white z-[100] opacity-0 transition-opacity duration-500';
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
setTimeout(() => {
|
||||
overlay.style.opacity = '1';
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/blog?from=post');
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
const url = window.location.href;
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title, url });
|
||||
} catch (err) {
|
||||
console.error('Share failed:', err);
|
||||
}
|
||||
} else {
|
||||
// Fallback: copy to clipboard
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
alert('Link copied to clipboard!');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="reading-progress-bar" />
|
||||
<nav
|
||||
id="top-nav"
|
||||
className={`fixed top-0 left-0 right-0 z-40 border-b border-slate-200 transition-all duration-300 ${
|
||||
isScrolled ? 'bg-white/95 backdrop-blur-md' : 'bg-white/80 backdrop-blur-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-full text-slate-600 hover:text-slate-900 hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100 group"
|
||||
aria-label="Back to home"
|
||||
>
|
||||
<svg className="w-5 h-5 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span className="font-bold uppercase tracking-widest text-xs">Zurück</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400 hidden sm:inline">
|
||||
{readingTime} min read
|
||||
</span>
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="share-button-top flex items-center gap-1.5 px-3 py-1.5 border border-slate-200 rounded-full text-slate-600 hover:text-slate-900 hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
||||
aria-label="Share this post"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="mt-16 pt-8 border-t border-slate-50 text-center">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="btn btn-primary group"
|
||||
>
|
||||
<svg className="w-4 h-4 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Alle Beiträge
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
67
apps/web/src/components/Button.tsx
Normal file
67
apps/web/src/components/Button.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface ButtonProps {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
variant?: 'primary' | 'outline';
|
||||
className?: string;
|
||||
showArrow?: boolean;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
href,
|
||||
children,
|
||||
variant = 'primary',
|
||||
className = "",
|
||||
showArrow = true
|
||||
}) => {
|
||||
const baseStyles = "inline-flex items-center gap-4 rounded-full font-bold uppercase tracking-widest transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] group";
|
||||
|
||||
const variants = {
|
||||
primary: "px-10 py-5 bg-slate-900 text-white hover:bg-slate-800 hover:-translate-y-1 hover:shadow-2xl hover:shadow-slate-900/20 text-sm",
|
||||
outline: "px-8 py-4 border border-slate-200 bg-white text-slate-900 hover:border-slate-400 hover:bg-slate-50 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-slate-100 text-sm"
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{children}
|
||||
{showArrow && <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />}
|
||||
</>
|
||||
);
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
return (
|
||||
<a href={href} className={`${baseStyles} ${variants[variant]} ${className}`}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={`${baseStyles} ${variants[variant]} ${className}`}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export const MotionButton: React.FC<ButtonProps> = ({
|
||||
href,
|
||||
children,
|
||||
variant = 'primary',
|
||||
className = "",
|
||||
showArrow = true
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Button href={href} variant={variant} className={className} showArrow={showArrow}>
|
||||
{children}
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
24
apps/web/src/components/CombinedQuotePDF.tsx
Normal file
24
apps/web/src/components/CombinedQuotePDF.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Document as PDFDocument } from '@react-pdf/renderer';
|
||||
import { EstimationPDF } from './EstimationPDF';
|
||||
import { AgbsPDF } from './AgbsPDF';
|
||||
|
||||
interface CombinedProps {
|
||||
estimationProps: any;
|
||||
showAgbs?: boolean;
|
||||
techDetails?: any[];
|
||||
principles?: any[];
|
||||
}
|
||||
|
||||
export const CombinedQuotePDF = ({ estimationProps, showAgbs = true, techDetails, principles, mode = 'full' }: CombinedProps & { mode?: 'estimation' | 'full' }) => {
|
||||
return (
|
||||
<PDFDocument title={`Mintel - ${estimationProps.state.companyName || estimationProps.state.name}`}>
|
||||
<EstimationPDF {...estimationProps} mode={mode} techDetails={techDetails} principles={principles} />
|
||||
{showAgbs && (
|
||||
<AgbsPDF mode={mode} state={estimationProps.state} headerIcon={estimationProps.headerIcon} footerLogo={estimationProps.footerLogo} />
|
||||
)}
|
||||
</PDFDocument>
|
||||
);
|
||||
};
|
||||
589
apps/web/src/components/ContactForm.tsx
Normal file
589
apps/web/src/components/ContactForm.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronRight, ChevronLeft, Send, Check, Sparkles, Info, ArrowRight } from 'lucide-react';
|
||||
import * as QRCode from 'qrcode';
|
||||
import * as confetti from 'canvas-confetti';
|
||||
|
||||
import { FormState, Step } from './ContactForm/types';
|
||||
import { PRICING, initialState } from './ContactForm/constants';
|
||||
import { calculateTotals } from '../logic/pricing/calculator';
|
||||
import { PriceCalculation } from './ContactForm/components/PriceCalculation';
|
||||
import { ShareModal } from './ShareModal';
|
||||
|
||||
// Steps
|
||||
import { TypeStep } from './ContactForm/steps/TypeStep';
|
||||
import { CompanyStep } from './ContactForm/steps/CompanyStep';
|
||||
import { PresenceStep } from './ContactForm/steps/PresenceStep';
|
||||
import { BaseStep } from './ContactForm/steps/BaseStep';
|
||||
import { FeaturesStep } from './ContactForm/steps/FeaturesStep';
|
||||
import { DesignStep } from './ContactForm/steps/DesignStep';
|
||||
import { AssetsStep } from './ContactForm/steps/AssetsStep';
|
||||
import { FunctionsStep } from './ContactForm/steps/FunctionsStep';
|
||||
import { ApiStep } from './ContactForm/steps/ApiStep';
|
||||
import { ContentStep } from './ContactForm/steps/ContentStep';
|
||||
import { LanguageStep } from './ContactForm/steps/LanguageStep';
|
||||
import { TimelineStep } from './ContactForm/steps/TimelineStep';
|
||||
import { ContactStep } from './ContactForm/steps/ContactStep';
|
||||
import { WebAppStep } from './ContactForm/steps/WebAppStep';
|
||||
|
||||
import {
|
||||
ConceptTarget,
|
||||
ConceptWebsite,
|
||||
ConceptPrototyping,
|
||||
ConceptCommunication,
|
||||
ConceptSystem,
|
||||
ConceptCode,
|
||||
ConceptAutomation,
|
||||
ConceptPrice,
|
||||
HeroArchitecture
|
||||
} from './Landing/ConceptIllustrations';
|
||||
|
||||
export interface ContactFormProps {
|
||||
initialStepIndex?: number;
|
||||
initialState?: FormState;
|
||||
onStepChange?: (index: number) => void;
|
||||
onStateChange?: (state: FormState) => void;
|
||||
}
|
||||
|
||||
export function ContactForm({ initialStepIndex, initialState: propState, onStepChange, onStateChange }: ContactFormProps = {}) {
|
||||
// Use a safe version of useRouter/useSearchParams that doesn't crash if not in a router context
|
||||
let router: any = null;
|
||||
let searchParams: any = null;
|
||||
try { router = useRouter(); } catch (e) { /* ignore */ }
|
||||
try { searchParams = useSearchParams(); } catch (e) { /* ignore */ }
|
||||
|
||||
const [internalStepIndex, setInternalStepIndex] = useState(0);
|
||||
const [internalState, setInternalState] = useState<FormState>(initialState);
|
||||
|
||||
// Sync with props if provided
|
||||
const stepIndex = initialStepIndex !== undefined ? initialStepIndex : internalStepIndex;
|
||||
const state = propState !== undefined ? propState : internalState;
|
||||
|
||||
const setStepIndex = (val: number) => {
|
||||
setInternalStepIndex(val);
|
||||
onStepChange?.(val);
|
||||
};
|
||||
|
||||
const setState = (val: any) => {
|
||||
if (typeof val === 'function') {
|
||||
setInternalState(prev => {
|
||||
const next = val(prev);
|
||||
onStateChange?.(next);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setInternalState(val);
|
||||
onStateChange?.(val);
|
||||
}
|
||||
};
|
||||
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [qrCodeData, setQrCodeData] = useState<string>('');
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
||||
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const formContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isRemotion = typeof window !== 'undefined' && (window as any).isRemotion;
|
||||
const [isClient, setIsClient] = useState(isRemotion);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRemotion) return;
|
||||
const handleScroll = () => {
|
||||
if (formContainerRef.current) {
|
||||
const rect = formContainerRef.current.getBoundingClientRect();
|
||||
setIsSticky(rect.top <= 80);
|
||||
}
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
handleScroll();
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [isRemotion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRemotion) setIsClient(true);
|
||||
}, [isRemotion]);
|
||||
|
||||
// URL Binding
|
||||
useEffect(() => {
|
||||
if (!searchParams) return;
|
||||
const step = searchParams.get('step');
|
||||
if (step) setStepIndex(parseInt(step));
|
||||
|
||||
const config = searchParams.get('config');
|
||||
if (config) {
|
||||
try {
|
||||
const decoded = JSON.parse(decodeURIComponent(escape(atob(config))));
|
||||
setInternalState((s: FormState) => ({ ...s, ...decoded }));
|
||||
} catch (e) {
|
||||
console.error("Failed to decode config", e);
|
||||
}
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const currentUrl = useMemo(() => {
|
||||
if (!isClient) return '';
|
||||
const params = new URLSearchParams();
|
||||
params.set('step', stepIndex.toString());
|
||||
|
||||
const configData = {
|
||||
projectType: state.projectType,
|
||||
companyName: state.companyName,
|
||||
employeeCount: state.employeeCount,
|
||||
existingWebsite: state.existingWebsite,
|
||||
socialMedia: state.socialMedia,
|
||||
socialMediaUrls: state.socialMediaUrls,
|
||||
existingDomain: state.existingDomain,
|
||||
wishedDomain: state.wishedDomain,
|
||||
websiteTopic: state.websiteTopic,
|
||||
selectedPages: state.selectedPages,
|
||||
otherPages: state.otherPages,
|
||||
otherPagesCount: state.otherPagesCount,
|
||||
features: state.features,
|
||||
otherFeatures: state.otherFeatures,
|
||||
otherFeaturesCount: state.otherFeaturesCount,
|
||||
functions: state.functions,
|
||||
otherFunctions: state.otherFunctions,
|
||||
otherFunctionsCount: state.otherFunctionsCount,
|
||||
apiSystems: state.apiSystems,
|
||||
otherTech: state.otherTech,
|
||||
otherTechCount: state.otherTechCount,
|
||||
assets: state.assets,
|
||||
otherAssets: state.otherAssets,
|
||||
otherAssetsCount: state.otherAssetsCount,
|
||||
cmsSetup: state.cmsSetup,
|
||||
languagesList: state.languagesList,
|
||||
deadline: state.deadline,
|
||||
designVibe: state.designVibe,
|
||||
colorScheme: state.colorScheme,
|
||||
targetAudience: state.targetAudience,
|
||||
userRoles: state.userRoles,
|
||||
dataSensitivity: state.dataSensitivity,
|
||||
platformType: state.platformType,
|
||||
dontKnows: state.dontKnows,
|
||||
visualStaging: state.visualStaging,
|
||||
complexInteractions: state.complexInteractions
|
||||
};
|
||||
|
||||
const stateString = btoa(unescape(encodeURIComponent(JSON.stringify(configData))));
|
||||
params.set('config', stateString);
|
||||
|
||||
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
|
||||
}, [state, stepIndex, isClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRemotion) return;
|
||||
if (currentUrl && router) {
|
||||
router.replace(currentUrl, { scroll: false });
|
||||
QRCode.toDataURL(currentUrl, { margin: 1, width: 200 }).then(setQrCodeData);
|
||||
}
|
||||
}, [currentUrl, router, isRemotion]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
return calculateTotals(state, PRICING);
|
||||
}, [state]);
|
||||
|
||||
const { totalPrice, monthlyPrice, totalPagesCount } = totals;
|
||||
|
||||
const updateState = (updates: Partial<FormState>) => {
|
||||
setState((s: FormState) => ({ ...s, ...updates }));
|
||||
};
|
||||
|
||||
const toggleItem = (list: string[], id: string) => {
|
||||
return list.includes(id) ? list.filter(i => i !== id) : [...list, id];
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
if (isRemotion) return;
|
||||
if (formContainerRef.current) {
|
||||
const offset = 120;
|
||||
const bodyRect = document.body.getBoundingClientRect().top;
|
||||
const elementRect = formContainerRef.current.getBoundingClientRect().top;
|
||||
const elementPosition = elementRect - bodyRect;
|
||||
const offsetPosition = elementPosition - offset;
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (stepIndex < activeSteps.length - 1) {
|
||||
setStepIndex(stepIndex + 1);
|
||||
if (!isRemotion) setTimeout(scrollToTop, 50);
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (stepIndex > 0) {
|
||||
setStepIndex(stepIndex - 1);
|
||||
if (!isRemotion) setTimeout(scrollToTop, 50);
|
||||
}
|
||||
};
|
||||
|
||||
const steps: Step[] = [
|
||||
{ id: 'type', title: 'Das Ziel', description: 'Was möchten Sie realisieren?', illustration: <ConceptTarget className="w-full h-full" />, chapter: 'strategy' },
|
||||
{ id: 'company', title: 'Unternehmen', description: 'Wer sind Sie?', illustration: <ConceptCommunication className="w-full h-full" />, chapter: 'strategy' },
|
||||
{ id: 'presence', title: 'Präsenz', description: 'Bestehende Kanäle von {company}.', illustration: <ConceptSystem className="w-full h-full" />, chapter: 'strategy' },
|
||||
{ id: 'features', title: 'Die Systeme', description: 'Welche inhaltlichen Bereiche planen wir für {company}?', illustration: <ConceptPrototyping className="w-full h-full" />, chapter: 'scope' },
|
||||
{ id: 'base', title: 'Die Seiten', description: 'Welche Seiten benötigen wir?', illustration: <ConceptWebsite className="w-full h-full" />, chapter: 'scope' },
|
||||
{ id: 'design', title: 'Design-Wünsche', description: 'Wie soll die neue Präsenz von {company} wirken?', illustration: <ConceptCommunication className="w-full h-full" />, chapter: 'creative' },
|
||||
{ id: 'assets', title: 'Ihre Assets', description: 'Was bringen Sie bereits mit?', illustration: <ConceptSystem className="w-full h-full" />, chapter: 'creative' },
|
||||
{ id: 'functions', title: 'Die Logik', description: 'Welche Funktionen werden benötigt?', illustration: <ConceptCode className="w-full h-full" />, chapter: 'tech' },
|
||||
{ id: 'api', title: 'Schnittstellen', description: 'Datenaustausch mit Drittsystemen.', illustration: <ConceptAutomation className="w-full h-full" />, chapter: 'tech' },
|
||||
{ id: 'content', title: 'Die Pflege', description: 'Wer kümmert sich um die Daten?', illustration: <ConceptPrice className="w-full h-full" />, chapter: 'tech' },
|
||||
{ id: 'language', title: 'Sprachen', description: 'Globale Reichweite planen.', illustration: <ConceptCommunication className="w-full h-full" />, chapter: 'tech' },
|
||||
{ id: 'timeline', title: 'Zeitplan', description: 'Wann soll das Projekt live gehen?', illustration: <HeroArchitecture className="w-full h-full" />, chapter: 'final' },
|
||||
{ id: 'contact', title: 'Abschluss', description: 'Erzählen Sie mir mehr über Ihr Vorhaben.', illustration: <ConceptCommunication className="w-full h-full" />, chapter: 'final' },
|
||||
{ id: 'webapp', title: 'Web App Details', description: 'Spezifische Anforderungen für {company}.', illustration: <ConceptSystem className="w-full h-full" />, chapter: 'scope' },
|
||||
];
|
||||
|
||||
const chapters = [
|
||||
{ id: 'strategy', title: 'Strategie' },
|
||||
{ id: 'scope', title: 'Umfang' },
|
||||
{ id: 'creative', title: 'Design' },
|
||||
{ id: 'tech', title: 'Technik' },
|
||||
{ id: 'final', title: 'Start' },
|
||||
];
|
||||
|
||||
const activeSteps = useMemo(() => {
|
||||
if (state.projectType === 'website') {
|
||||
return steps.filter(s => s.id !== 'webapp');
|
||||
}
|
||||
// Web App flow
|
||||
return [
|
||||
steps.find(s => s.id === 'type')!,
|
||||
steps.find(s => s.id === 'company')!,
|
||||
steps.find(s => s.id === 'presence')!,
|
||||
steps.find(s => s.id === 'webapp')!,
|
||||
{ ...steps.find(s => s.id === 'functions')!, title: 'Funktionen', description: 'Kern-Features Ihrer Anwendung.' },
|
||||
{ ...steps.find(s => s.id === 'api')!, title: 'Integrationen', description: 'Anbindung an bestehende Systeme.' },
|
||||
steps.find(s => s.id === 'timeline')!,
|
||||
steps.find(s => s.id === 'contact')!,
|
||||
];
|
||||
}, [state.projectType, state.companyName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stepIndex >= activeSteps.length) setStepIndex(activeSteps.length - 1);
|
||||
}, [activeSteps, stepIndex]);
|
||||
|
||||
const renderStepContent = () => {
|
||||
const currentStep = activeSteps[stepIndex];
|
||||
switch (currentStep.id) {
|
||||
case 'type':
|
||||
return <TypeStep state={state} updateState={updateState} />;
|
||||
case 'company':
|
||||
return <CompanyStep state={state} updateState={updateState} />;
|
||||
case 'presence':
|
||||
return <PresenceStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
||||
case 'base':
|
||||
return <BaseStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
||||
case 'features':
|
||||
return <FeaturesStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
||||
case 'design':
|
||||
return <DesignStep state={state} updateState={updateState} />;
|
||||
case 'assets':
|
||||
return <AssetsStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
||||
case 'functions':
|
||||
return <FunctionsStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
||||
case 'api':
|
||||
return <ApiStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
||||
case 'content':
|
||||
return <ContentStep state={state} updateState={updateState} />;
|
||||
case 'language':
|
||||
return <LanguageStep state={state} updateState={updateState} />;
|
||||
case 'timeline':
|
||||
return <TimelineStep state={state} updateState={updateState} />;
|
||||
case 'contact':
|
||||
return <ContactStep state={state} updateState={updateState} />;
|
||||
case 'webapp':
|
||||
return <WebAppStep state={state} updateState={updateState} />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (stepIndex === activeSteps.length - 1) {
|
||||
// Handle submission
|
||||
const mailBody = `
|
||||
Name: ${state.name}
|
||||
Email: ${state.email}
|
||||
Rolle: ${state.role}
|
||||
Projekt: ${state.projectType}
|
||||
Konfiguration: ${currentUrl}
|
||||
|
||||
Nachricht:
|
||||
${state.message}
|
||||
`;
|
||||
window.location.href = `mailto:marc@mintel.me?subject=Projektanfrage: ${state.name}&body=${encodeURIComponent(mailBody)}`;
|
||||
|
||||
// Celebration!
|
||||
const duration = 5 * 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 = !isRemotion ? 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) : null;
|
||||
|
||||
setIsSubmitted(true);
|
||||
} else {
|
||||
nextStep();
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
setIsShareModalOpen(true);
|
||||
};
|
||||
|
||||
if (isSubmitted) {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="p-12 md:p-24 bg-slate-900 text-white rounded-[4rem] text-center space-y-12">
|
||||
<div className="w-24 h-24 bg-white text-slate-900 rounded-full flex items-center justify-center mx-auto"><Check size={48} strokeWidth={3} /></div>
|
||||
<div className="space-y-6"><h2 className="text-5xl font-bold tracking-tight">Anfrage gesendet!</h2><p className="text-slate-400 text-2xl max-w-2xl mx-auto leading-relaxed">Vielen Dank, {state.name.split(' ')[0]}. Ich melde mich innerhalb von 24 Stunden bei Ihnen.</p></div>
|
||||
<button type="button" onClick={() => { setIsSubmitted(false); setStepIndex(0); setState(initialState); }} className="text-slate-400 hover:text-white transition-colors underline underline-offset-8 text-lg focus:outline-none overflow-hidden relative rounded-full">Neue Anfrage starten</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={formContainerRef} className="grid grid-cols-1 lg:grid-cols-12 gap-16 items-start">
|
||||
<div className="lg:col-span-8 space-y-12">
|
||||
<div
|
||||
className={`sticky top-[80px] z-40 transition-all duration-500 ${isSticky ? 'bg-white/80 backdrop-blur-md py-4 -mx-12 px-12 -mt-px border-b border-slate-50' : 'bg-transparent py-6 border-none'}`}
|
||||
>
|
||||
<div className={`flex flex-col ${isSticky ? 'gap-4' : 'gap-8'}`}>
|
||||
<div className="flex flex-row items-center justify-between gap-8">
|
||||
<div className="flex items-center gap-6">
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: isSticky ? 0.7 : 1,
|
||||
width: isSticky ? 80 : 128,
|
||||
height: isSticky ? 80 : 128,
|
||||
borderRadius: isSticky ? '1.75rem' : '2.5rem'
|
||||
}}
|
||||
className="shrink-0 bg-slate-50 flex items-center justify-center relative shadow-inner z-10"
|
||||
>
|
||||
<div className="p-3 w-full h-full flex items-center justify-center overflow-hidden rounded-[inherit]">
|
||||
{activeSteps[stepIndex].illustration}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{!isSticky && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className="absolute -bottom-2 -right-2 w-10 h-10 bg-slate-900 text-white rounded-full flex items-center justify-center font-bold text-sm border-4 border-white shadow-xl z-20"
|
||||
>
|
||||
{stepIndex + 1}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
<div className="space-y-1 min-w-0">
|
||||
<motion.div
|
||||
animate={{ opacity: isSticky ? 0 : 1, height: isSticky ? 0 : 'auto', marginBottom: isSticky ? 0 : 4 }}
|
||||
className="flex items-center gap-3 overflow-hidden"
|
||||
>
|
||||
<span className="text-xs font-bold uppercase tracking-[0.2em] text-slate-400 flex items-center gap-2">
|
||||
<Sparkles size={12} className="text-slate-300" />
|
||||
Schritt {stepIndex + 1} / {activeSteps.length}
|
||||
</span>
|
||||
</motion.div>
|
||||
<motion.h3
|
||||
animate={{
|
||||
fontSize: isSticky ? '1.5rem' : '2.25rem',
|
||||
lineHeight: isSticky ? '2rem' : '2.5rem',
|
||||
color: isSticky ? '#0f172a' : '#0f172a'
|
||||
}}
|
||||
className="font-black tracking-tight truncate"
|
||||
>
|
||||
{activeSteps[stepIndex].title.replace('{company}', state.companyName || 'Ihr Unternehmen')}
|
||||
</motion.h3>
|
||||
<motion.p
|
||||
animate={{
|
||||
fontSize: isSticky ? '0.875rem' : '1.125rem',
|
||||
lineHeight: isSticky ? '1.25rem' : '1.75rem'
|
||||
}}
|
||||
className="text-slate-500 leading-relaxed max-w-2xl truncate overflow-hidden"
|
||||
>
|
||||
{activeSteps[stepIndex].description.replace('{company}', state.companyName || 'Ihr Unternehmen')}
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{stepIndex > 0 ? (
|
||||
<motion.button
|
||||
whileHover={{ x: -3, backgroundColor: '#f8fafc' }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={prevStep}
|
||||
className={`flex items-center justify-center gap-2 rounded-full border-2 border-slate-100 transition-all font-bold focus:outline-none whitespace-nowrap ${isSticky ? 'px-5 py-2 text-sm' : 'px-8 py-4 text-lg'}`}
|
||||
>
|
||||
<ChevronLeft size={isSticky ? 16 : 20} /> Zurück
|
||||
</motion.button>
|
||||
) : <div className={isSticky ? 'w-0' : 'w-32'} />}
|
||||
|
||||
{stepIndex < activeSteps.length - 1 ? (
|
||||
<motion.button
|
||||
id="focus-target-next"
|
||||
whileHover={{ x: 3, scale: 1.02 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={nextStep}
|
||||
className={`flex items-center justify-center gap-2 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition-all font-bold shadow-xl shadow-slate-200 focus:outline-none group whitespace-nowrap ${isSticky ? 'px-6 py-2 text-sm' : 'px-10 py-4 text-lg'}`}
|
||||
>
|
||||
Weiter <ChevronRight size={isSticky ? 16 : 20} className="transition-transform group-hover:translate-x-1" />
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="submit"
|
||||
form="contact-form"
|
||||
disabled={!state.email || !state.name}
|
||||
className={`flex items-center justify-center gap-2 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition-all font-bold disabled:opacity-50 disabled:cursor-not-allowed shadow-xl shadow-slate-200 focus:outline-none group whitespace-nowrap ${isSticky ? 'px-6 py-2 text-sm' : 'px-10 py-4 text-lg'}`}
|
||||
>
|
||||
Senden <Send size={isSticky ? 16 : 20} className="transition-transform group-hover:translate-x-1 group-hover:-translate-y-1" />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className={`flex gap-1.5 transition-all duration-500 ${isSticky ? 'h-1' : 'h-2.5'}`}>
|
||||
{activeSteps.map((step, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 h-full flex items-center relative"
|
||||
onMouseEnter={() => setHoveredStep(i)}
|
||||
onMouseLeave={() => setHoveredStep(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStepIndex(i);
|
||||
setTimeout(scrollToTop, 50);
|
||||
}}
|
||||
className={`w-full h-full rounded-full transition-all duration-700 ${i === stepIndex ? 'bg-slate-900 scale-y-150 shadow-lg shadow-slate-200' :
|
||||
i < stepIndex ? 'bg-slate-400' : 'bg-slate-100'
|
||||
} cursor-pointer focus:outline-none p-0 border-none relative group`}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{hoveredStep === i && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 5, x: "-50%", scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: isSticky ? -35 : -40, x: "-50%", scale: 1 }}
|
||||
exit={{ opacity: 0, y: 5, x: "-50%", scale: 0.9 }}
|
||||
className="absolute left-1/2 px-3 py-1.5 bg-white text-slate-900 text-[10px] font-black uppercase tracking-[0.15em] rounded-md whitespace-nowrap pointer-events-none z-50 shadow-[0_10px_30px_rgba(0,0,0,0.1)] border border-slate-100"
|
||||
>
|
||||
{step.title.replace('{company}', state.companyName || 'Unternehmen')}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isSticky && (
|
||||
<div className="flex justify-between mt-4 px-1">
|
||||
{chapters.map((chapter, idx) => {
|
||||
const chapterSteps = activeSteps.filter(s => s.chapter === chapter.id);
|
||||
if (chapterSteps.length === 0) return null;
|
||||
|
||||
const firstStepIdx = activeSteps.indexOf(chapterSteps[0]);
|
||||
const lastStepIdx = activeSteps.indexOf(chapterSteps[chapterSteps.length - 1]);
|
||||
const isActive = stepIndex >= firstStepIdx && stepIndex <= lastStepIdx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={chapter.id}
|
||||
className={`text-[10px] font-bold uppercase tracking-[0.2em] transition-colors duration-500 ${isActive ? 'text-slate-900' : 'text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{chapter.title}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="contact-form" onSubmit={handleSubmit} className="min-h-[450px] relative pt-12">
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeSteps[stepIndex].id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
>
|
||||
{renderStepContent()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Contextual Help / Why this matters */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
className="mt-24 p-10 bg-slate-50 rounded-[3rem] border border-slate-100 flex gap-8 items-start relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-12 opacity-[0.03] pointer-events-none">
|
||||
<Sparkles size={160} />
|
||||
</div>
|
||||
<div className="w-14 h-14 shrink-0 bg-white rounded-2xl flex items-center justify-center text-slate-900 shadow-sm relative z-10">
|
||||
<Info size={28} />
|
||||
</div>
|
||||
<div className="space-y-2 relative z-10">
|
||||
<h4 className="text-xl font-bold text-slate-900">Warum das wichtig ist</h4>
|
||||
<p className="text-lg text-slate-500 leading-relaxed max-w-2xl">
|
||||
{stepIndex === 0 && "Die Wahl zwischen Website und Web App bestimmt die technologische Basis. Web Apps nutzen oft Frameworks wie React oder Next.js für hochgradig interaktive Prozesse, während Websites auf Content-Präsentation optimiert sind."}
|
||||
{stepIndex === 1 && "Ihr Unternehmen und dessen Größe helfen mir, die Skalierbarkeit und die notwendige Infrastruktur von Beginn an richtig zu dimensionieren."}
|
||||
{stepIndex === 2 && "Bestehende Kanäle geben Aufschluss über Ihre aktuelle Markenidentität. Wir können diese entweder konsequent weiterführen oder einen bewussten Neuanfang wagen."}
|
||||
{stepIndex > 2 && "Jede Auswahl beeinflusst die Komplexität und damit auch die Entwicklungszeit. Mein Ziel ist es, für Sie das beste Preis-Leistungs-Verhältnis zu finden."}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</form>
|
||||
</div>
|
||||
<PriceCalculation
|
||||
state={state}
|
||||
totals={totals}
|
||||
isClient={isClient}
|
||||
qrCodeData={qrCodeData}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
|
||||
<ShareModal
|
||||
isOpen={isShareModalOpen}
|
||||
onClose={() => setIsShareModalOpen(false)}
|
||||
url={currentUrl}
|
||||
qrCodeData={qrCodeData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'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>;
|
||||
}
|
||||
48
apps/web/src/components/ContactForm/components/Checkbox.tsx
Normal file
48
apps/web/src/components/ContactForm/components/Checkbox.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'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-5 rounded-[2rem] border-2 text-left transition-all duration-300 flex items-start gap-4 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-1 w-8 h-8 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 shadow-lg' : '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={18} strokeWidth={4} />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<h4 className={`text-2xl font-bold mb-1 transition-colors duration-500 ${checked ? 'text-white' : 'text-slate-900'}`}>{label}</h4>
|
||||
{desc && <p className={`text-lg 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/components/ContactForm/components/Input.tsx
Normal file
36
apps/web/src/components/ContactForm/components/Input.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState, Totals } from '../types';
|
||||
import { PRICING } from '../constants';
|
||||
import { AnimatedNumber } from './AnimatedNumber';
|
||||
import { ConceptPrice, ConceptAutomation } from '../../Landing/ConceptIllustrations';
|
||||
import { Info, Download, Share2, RefreshCw } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { EstimationPDF } from '../../EstimationPDF';
|
||||
import IconWhite from '../../../assets/logo/Icon White Transparent.png';
|
||||
import LogoBlack from '../../../assets/logo/Logo Black Transparent.png';
|
||||
|
||||
// Dynamically import PDF components to avoid SSR issues
|
||||
const PDFDownloadLink = dynamic(
|
||||
() => import('@react-pdf/renderer').then((mod) => mod.PDFDownloadLink),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
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 { pdf } = await import('@react-pdf/renderer');
|
||||
|
||||
const doc = <EstimationPDF
|
||||
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}
|
||||
/>;
|
||||
|
||||
// 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-[3rem] p-6 space-y-6">
|
||||
<div className="space-y-6">
|
||||
{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">CMS Setup & Anbindung</span><span className="font-medium text-slate-900">{(PRICING.CMS_SETUP + 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-Paket</span>
|
||||
<span className="text-base font-bold text-slate-900">{monthlyPrice.toLocaleString()} € / Monat</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/components/ContactForm/constants.tsx
Normal file
72
apps/web/src/components/ContactForm/constants.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as React from 'react';
|
||||
import { 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]
|
||||
}));
|
||||
111
apps/web/src/components/ContactForm/steps/ApiStep.tsx
Normal file
111
apps/web/src/components/ContactForm/steps/ApiStep.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'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-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<Share2 size={24} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="text-2xl 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-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<ListPlus size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl 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) });
|
||||
}
|
||||
}
|
||||
98
apps/web/src/components/ContactForm/steps/AssetsStep.tsx
Normal file
98
apps/web/src/components/ContactForm/steps/AssetsStep.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'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, AnimatePresence } from 'framer-motion';
|
||||
import { Minus, Plus, 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-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<Briefcase size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl 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-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<ListPlus size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl 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>
|
||||
);
|
||||
}
|
||||
168
apps/web/src/components/ContactForm/steps/BaseStep.tsx
Normal file
168
apps/web/src/components/ContactForm/steps/BaseStep.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'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-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
<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-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 bg-slate-900 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-slate-200">
|
||||
<FileText size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="text-3xl font-bold text-slate-900 tracking-tight">Die Seitenstruktur</h4>
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">Essenziell</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-400 mt-1">
|
||||
<HelpCircle size={14} className="shrink-0" />
|
||||
<span className="text-base">Wählen Sie die Bausteine Ihrer neuen Website.</span>
|
||||
</div>
|
||||
</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-6">
|
||||
{[
|
||||
{ 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-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<ListPlus size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl 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-10 bg-slate-900 text-white rounded-[3rem] space-y-6 shadow-2xl shadow-slate-200 relative overflow-hidden group"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-8 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<ListPlus size={120} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-8 relative z-10">
|
||||
<div>
|
||||
<h4 className="text-2xl font-bold text-white">Noch mehr Seiten?</h4>
|
||||
<p className="text-lg text-slate-400 mt-1">Falls Sie die Namen noch nicht wissen, aber die Menge schätzen können.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ otherPagesCount: Math.max(0, state.otherPagesCount - 1) })}
|
||||
className="w-16 h-16 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={28} />
|
||||
</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-6xl font-bold w-16 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-16 h-16 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={28} />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
apps/web/src/components/ContactForm/steps/CompanyStep.tsx
Normal file
64
apps/web/src/components/ContactForm/steps/CompanyStep.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'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-16">
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="space-y-8" id="focus-target-company">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<Building2 size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl 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-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<Users size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Mitarbeiteranzahl</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{EMPLOYEE_OPTIONS.map((option, index) => (
|
||||
<motion.button
|
||||
key={option.id}
|
||||
whileHover={{ y: -5 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ employeeCount: option.id })}
|
||||
className={`p-6 rounded-[2rem] border-2 transition-all duration-300 font-bold text-lg ${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>
|
||||
);
|
||||
}
|
||||
170
apps/web/src/components/ContactForm/steps/ContactStep.tsx
Normal file
170
apps/web/src/components/ContactForm/steps/ContactStep.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
184
apps/web/src/components/ContactForm/steps/ContentStep.tsx
Normal file
184
apps/web/src/components/ContactForm/steps/ContentStep.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'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-16">
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="flex flex-col md:flex-row items-center justify-between p-10 bg-white border border-slate-100 rounded-[3rem] gap-8">
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<Settings2 size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl 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-[3rem] border border-slate-100 space-y-10">
|
||||
<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={24} />
|
||||
</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-[2rem] 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={24} />
|
||||
</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-8 p-10 bg-white border border-slate-100 rounded-[3rem]">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-2xl 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>
|
||||
);
|
||||
}
|
||||
242
apps/web/src/components/ContactForm/steps/DesignStep.tsx
Normal file
242
apps/web/src/components/ContactForm/steps/DesignStep.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
'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-16">
|
||||
{/* Design Vibe */}
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-2xl 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-2xl 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-8 p-10 bg-slate-50 rounded-[3rem] 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-8">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-2xl 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-[3rem]">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
95
apps/web/src/components/ContactForm/steps/FeaturesStep.tsx
Normal file
95
apps/web/src/components/ContactForm/steps/FeaturesStep.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'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-16">
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<LayoutGrid size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="text-2xl 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-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<ListPlus size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl 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>
|
||||
);
|
||||
}
|
||||
142
apps/web/src/components/ContactForm/steps/FunctionsStep.tsx
Normal file
142
apps/web/src/components/ContactForm/steps/FunctionsStep.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'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-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<Cpu size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl 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-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<ListPlus size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl 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>
|
||||
);
|
||||
}
|
||||
168
apps/web/src/components/ContactForm/steps/LanguageStep.tsx
Normal file
168
apps/web/src/components/ContactForm/steps/LanguageStep.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'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-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<Globe size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl 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-8">
|
||||
<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-[3rem] space-y-8 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={24} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
150
apps/web/src/components/ContactForm/steps/PresenceStep.tsx
Normal file
150
apps/web/src/components/ContactForm/steps/PresenceStep.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'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-16">
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<Globe size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl 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-8">
|
||||
<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-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||
<Share2 size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl 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-[2rem] 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-[3rem] 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>
|
||||
);
|
||||
}
|
||||
85
apps/web/src/components/ContactForm/steps/TimelineStep.tsx
Normal file
85
apps/web/src/components/ContactForm/steps/TimelineStep.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'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-2xl 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-[2rem] 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-[2rem] 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={24} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/components/ContactForm/steps/TypeStep.tsx
Normal file
51
apps/web/src/components/ContactForm/steps/TypeStep.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'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-8">
|
||||
{[
|
||||
{ id: 'website', label: 'Website', desc: 'Klassische Webpräsenz, Portfolio oder Blog.', illustration: <ConceptWebsite className="w-20 h-20 mb-6" /> },
|
||||
{ id: 'web-app', label: 'Web App', desc: 'Internes Tool, Dashboard oder Prozess-Logik.', illustration: <ConceptSystem className="w-20 h-20 mb-6" /> },
|
||||
].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-12 rounded-[4rem] 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-4 mb-6">
|
||||
<h4 className={`text-5xl 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-2xl 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-8 right-8 w-6 h-6 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>
|
||||
);
|
||||
}
|
||||
144
apps/web/src/components/ContactForm/steps/WebAppStep.tsx
Normal file
144
apps/web/src/components/ContactForm/steps/WebAppStep.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'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-2xl font-bold text-slate-900 flex items-center gap-3">
|
||||
<Users size={24} 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-[2rem] 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-2xl 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-2xl font-bold text-slate-900 flex items-center gap-3">
|
||||
<Monitor size={24} 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={24} /> },
|
||||
{ id: 'mobile', label: 'Mobile First', icon: <Smartphone size={24} /> },
|
||||
{ id: 'pwa', label: 'PWA (Installierbar)', icon: <Globe size={24} /> },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => updateState({ platformType: opt.id })}
|
||||
className={`p-8 rounded-[2rem] 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-2xl font-bold text-slate-900 flex items-center gap-3">
|
||||
<Shield size={24} 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-[2rem] 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-[3rem] border border-slate-100 space-y-6">
|
||||
<h4 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
|
||||
<Lock size={24} 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>
|
||||
);
|
||||
}
|
||||
85
apps/web/src/components/ContactForm/types.ts
Normal file
85
apps/web/src/components/ContactForm/types.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export type ProjectType = 'website' | 'web-app';
|
||||
|
||||
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;
|
||||
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[];
|
||||
}
|
||||
6
apps/web/src/components/ContactForm/utils.ts
Normal file
6
apps/web/src/components/ContactForm/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { calculatePositions as logicCalculatePositions } from '../../logic/pricing';
|
||||
import { FormState } from './types';
|
||||
|
||||
export type { Position } from '../../logic/pricing';
|
||||
|
||||
export const calculatePositions = (state: FormState, pricing: any) => logicCalculatePositions(state as any, pricing);
|
||||
35
apps/web/src/components/Embeds/index.ts
Normal file
35
apps/web/src/components/Embeds/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Embed Components Index
|
||||
|
||||
// Re-export for convenience
|
||||
export { YouTubeEmbed } from '../YouTubeEmbed';
|
||||
export { TwitterEmbed } from '../TwitterEmbed';
|
||||
export { GenericEmbed } from '../GenericEmbed';
|
||||
export { Mermaid } from '../Mermaid';
|
||||
|
||||
// Type definitions for props
|
||||
export interface MermaidProps {
|
||||
graph: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
129
apps/web/src/components/EstimationPDF.tsx
Normal file
129
apps/web/src/components/EstimationPDF.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { calculatePositions } from '../logic/pricing';
|
||||
import { Page as PDFPage } from '@react-pdf/renderer';
|
||||
import { pdfStyles } from './pdf/SharedUI';
|
||||
import { DINLayout } from './pdf/DINLayout';
|
||||
import { SimpleLayout } from './pdf/SimpleLayout';
|
||||
|
||||
// Modules
|
||||
import { FrontPageModule } from './pdf/modules/FrontPageModule';
|
||||
import { BriefingModule } from './pdf/modules/BriefingModule';
|
||||
import { SitemapModule } from './pdf/modules/SitemapModule';
|
||||
import { EstimationModule } from './pdf/modules/EstimationModule';
|
||||
import { TransparenzModule, techPageModule as TechPageModule, PrinciplesModule } from './pdf/modules/CommonModules';
|
||||
import { AboutModule, CrossSellModule } from './pdf/modules/BrandingModules';
|
||||
|
||||
interface PDFProps {
|
||||
state: any;
|
||||
totalPrice: number;
|
||||
monthlyPrice: number;
|
||||
totalPagesCount: number;
|
||||
pricing: any;
|
||||
mode?: 'estimation' | 'full';
|
||||
headerIcon?: string;
|
||||
footerLogo?: string;
|
||||
techDetails?: { t: string, d: string }[];
|
||||
principles?: { t: string, d: string }[];
|
||||
}
|
||||
|
||||
export const EstimationPDF = ({
|
||||
state,
|
||||
totalPrice,
|
||||
pricing,
|
||||
mode = 'full',
|
||||
headerIcon,
|
||||
footerLogo,
|
||||
techDetails,
|
||||
principles,
|
||||
...props
|
||||
}: 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 = {
|
||||
state,
|
||||
date,
|
||||
icon: headerIcon,
|
||||
footerLogo,
|
||||
companyData,
|
||||
bankData
|
||||
};
|
||||
|
||||
if (mode === 'estimation') {
|
||||
return (
|
||||
<DINLayout {...commonProps} showAddress={true} showFooterDetails={true}>
|
||||
<EstimationModule state={state} positions={positions} totalPrice={totalPrice} date={date} />
|
||||
</DINLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Full Portfolio Mode
|
||||
let pageCounter = 1;
|
||||
const getPageNum = () => (pageCounter++).toString().padStart(2, '0');
|
||||
|
||||
return (
|
||||
<>
|
||||
<PDFPage size="A4" style={pdfStyles.titlePage}>
|
||||
<FrontPageModule state={state} headerIcon={headerIcon} date={date} />
|
||||
</PDFPage>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<BriefingModule state={state} />
|
||||
</SimpleLayout>
|
||||
|
||||
{state.sitemap && state.sitemap.length > 0 && (
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<SitemapModule state={state} />
|
||||
</SimpleLayout>
|
||||
)}
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<EstimationModule state={state} positions={positions} totalPrice={totalPrice} date={date} />
|
||||
</SimpleLayout>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<TransparenzModule pricing={pricing} />
|
||||
</SimpleLayout>
|
||||
|
||||
{techDetails && techDetails.length > 0 && (
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<TechPageModule techDetails={techDetails} headerIcon={headerIcon} />
|
||||
</SimpleLayout>
|
||||
)}
|
||||
|
||||
{principles && principles.length > 0 && (
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<PrinciplesModule principles={principles} />
|
||||
</SimpleLayout>
|
||||
)}
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<AboutModule />
|
||||
</SimpleLayout>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<CrossSellModule state={state} />
|
||||
</SimpleLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
192
apps/web/src/components/FileExample.tsx
Normal file
192
apps/web/src/components/FileExample.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'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';
|
||||
|
||||
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,
|
||||
id
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const safeId = String(id).replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
const headerId = `file-example-header-${safeId}`;
|
||||
const contentId = `file-example-content-${safeId}`;
|
||||
|
||||
const fileExtension = filename.split('.').pop() || language;
|
||||
const prismLanguage = prismLanguageMap[fileExtension] || 'markup';
|
||||
|
||||
const highlightedCode = Prism.highlight(
|
||||
content,
|
||||
Prism.languages[prismLanguage] || Prism.languages.markup,
|
||||
prismLanguage,
|
||||
);
|
||||
|
||||
const toggleExpand = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
if (!isExpanded) {
|
||||
setTimeout(() => {
|
||||
contentRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 120);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="file-example w-full bg-white border border-slate-200 rounded-2xl overflow-hidden transition-all duration-300"
|
||||
data-file-example
|
||||
data-expanded={isExpanded}
|
||||
>
|
||||
<div
|
||||
className="px-4 py-3 flex items-center justify-between gap-3 cursor-pointer select-none bg-white hover:bg-slate-50 transition-colors"
|
||||
onClick={toggleExpand}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleExpand();
|
||||
}
|
||||
}}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={contentId}
|
||||
id={headerId}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<svg
|
||||
className={`w-3 h-3 text-slate-400 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
|
||||
<span className="text-xs font-mono text-slate-900 truncate" title={filename}>{filename}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className={`copy-btn h-8 w-8 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 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100 ${isCopied ? 'copied' : ''}`}
|
||||
onClick={handleCopy}
|
||||
title="Copy to clipboard"
|
||||
aria-label={`Copy ${filename} to clipboard`}
|
||||
data-copied={isCopied}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<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="download-btn h-8 w-8 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 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
||||
onClick={handleDownload}
|
||||
title="Download file"
|
||||
aria-label={`Download ${filename}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`file-example__content overflow-hidden transition-[max-height,opacity] duration-200 ease-out bg-white ${isExpanded ? 'max-h-[22rem] opacity-100' : 'max-h-0 opacity-0'}`}
|
||||
id={contentId}
|
||||
role="region"
|
||||
aria-labelledby={headerId}
|
||||
>
|
||||
<pre
|
||||
className="m-0 p-6 overflow-x-auto overflow-y-auto text-[13px] leading-[1.65] font-mono text-slate-800 hide-scrollbar border-t border-slate-200"
|
||||
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", maxHeight: "22rem" }}
|
||||
>
|
||||
<code className={`language-${prismLanguage}`} dangerouslySetInnerHTML={{ __html: highlightedCode }}></code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
86
apps/web/src/components/FileExamplesList.tsx
Normal file
86
apps/web/src/components/FileExamplesList.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { FileExample } from './FileExample';
|
||||
import type { FileExampleGroup } from '../data/fileExamples';
|
||||
|
||||
interface FileExamplesListProps {
|
||||
groups: FileExampleGroup[];
|
||||
}
|
||||
|
||||
export const FileExamplesList: React.FC<FileExamplesListProps> = ({ groups }) => {
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleAllInGroup = (groupId: string, files: any[]) => {
|
||||
const isAnyExpanded = files.some(f => expandedGroups[f.id]);
|
||||
const newExpanded = { ...expandedGroups };
|
||||
files.forEach(f => {
|
||||
newExpanded[f.id] = !isAnyExpanded;
|
||||
});
|
||||
setExpandedGroups(newExpanded);
|
||||
};
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg px-4 py-6 text-center">
|
||||
<svg className="w-6 h-6 mx-auto text-slate-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="text-slate-500 text-sm">No files found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{groups.map((group) => (
|
||||
<section
|
||||
key={group.groupId}
|
||||
className="not-prose bg-white border border-slate-200/80 rounded-lg w-full overflow-hidden"
|
||||
data-file-examples-group
|
||||
>
|
||||
<header className="px-3 py-2 grid grid-cols-[1fr_auto] items-center gap-3 border-b border-slate-200/80 bg-white">
|
||||
<h3 className="m-0 text-xs font-semibold text-slate-900 truncate tracking-tight leading-none">{group.title}</h3>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-slate-600 bg-slate-100/80 border border-slate-200/60 rounded-full px-2 py-0.5 tabular-nums">
|
||||
{group.files.length} files
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="toggle-all-btn h-8 w-8 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 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
||||
title="Toggle all"
|
||||
onClick={() => toggleAllInGroup(group.groupId, group.files)}
|
||||
>
|
||||
{group.files.some(f => expandedGroups[f.id]) ? (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
{group.files.map((file) => (
|
||||
<FileExample
|
||||
key={file.id}
|
||||
filename={file.filename}
|
||||
content={file.content}
|
||||
language={file.language}
|
||||
description={file.description}
|
||||
tags={file.tags}
|
||||
id={file.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
35
apps/web/src/components/Footer.tsx
Normal file
35
apps/web/src/components/Footer.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import Image from 'next/image';
|
||||
import * as React from 'react';
|
||||
|
||||
import LogoBlack from '../assets/logo/Logo Black Transparent.svg';
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="py-16 mt-24 border-t border-slate-100 bg-white relative z-10">
|
||||
<div className="narrow-container">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-end">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Image
|
||||
src={LogoBlack}
|
||||
alt="Marc Mintel"
|
||||
height={72}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:items-end gap-4 text-sm font-mono text-slate-300 uppercase tracking-widest">
|
||||
<span>© {currentYear}</span>
|
||||
<div className="flex gap-8">
|
||||
<a href="/about" className="hover:text-slate-900 transition-colors no-underline">Über mich</a>
|
||||
<a href="/contact" className="hover:text-slate-900 transition-colors no-underline">Kontakt</a>
|
||||
<a href="https://github.com/marcmintel" className="hover:text-slate-900 transition-colors no-underline">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
98
apps/web/src/components/GenericEmbed.tsx
Normal file
98
apps/web/src/components/GenericEmbed.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
80
apps/web/src/components/Header.tsx
Normal file
80
apps/web/src/components/Header.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
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 [, setIsScrolled] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const isActive = (path: string) => pathname === path;
|
||||
|
||||
return (
|
||||
<header className="bg-white/80 backdrop-blur-md sticky top-0 z-50 border-b border-slate-50">
|
||||
<div className="narrow-container py-4 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-4 group">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-black rounded-xl flex items-center justify-center group-hover:scale-105 transition-all duration-500 shadow-sm shrink-0">
|
||||
<Image
|
||||
src={IconWhite}
|
||||
alt="Marc Mintel Icon"
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-8">
|
||||
<Link
|
||||
href="/about"
|
||||
className={`text-xs font-bold uppercase tracking-widest transition-colors ${isActive('/about') ? 'text-slate-900' : 'text-slate-400 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
Über mich
|
||||
</Link>
|
||||
<Link
|
||||
href="/websites"
|
||||
className={`text-xs font-bold uppercase tracking-widest transition-colors ${isActive('/websites') ? 'text-slate-900' : 'text-slate-400 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
Websites
|
||||
</Link>
|
||||
<Link
|
||||
href="/case-studies"
|
||||
className={`text-xs font-bold uppercase tracking-widest transition-colors ${isActive('/case-studies') || pathname?.startsWith('/case-studies/') ? 'text-slate-900' : 'text-slate-400 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
Case Studies
|
||||
</Link>
|
||||
<Link
|
||||
href="/blog"
|
||||
className={`text-xs font-bold uppercase tracking-widest transition-colors ${isActive('/blog') || pathname?.startsWith('/blog/') ? 'text-slate-900' : 'text-slate-400 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
Blog
|
||||
</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 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
||||
>
|
||||
Anfrage
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
54
apps/web/src/components/Hero.tsx
Normal file
54
apps/web/src/components/Hero.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
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-20 md:py-24 overflow-hidden">
|
||||
{/* 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: '40px 40px'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="container relative z-10">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
{/* Main heading */}
|
||||
<h1 className="text-4xl md:text-6xl font-bold mb-6 animate-slide-up">
|
||||
Digital Problem Solver
|
||||
</h1>
|
||||
|
||||
<p className="text-xl md:text-2xl text-slate-300 mb-8 leading-relaxed animate-fade-in">
|
||||
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-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>
|
||||
);
|
||||
};
|
||||
339
apps/web/src/components/IframeSection.tsx
Normal file
339
apps/web/src/components/IframeSection.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '../utils/cn';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { MonoLabel } from './Typography';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable Browser UI components to maintain consistency
|
||||
*/
|
||||
const BrowserChrome: React.FC<{ url: string; minimal?: boolean }> = ({ url, minimal }) => {
|
||||
if (minimal) return null;
|
||||
return (
|
||||
<div className="h-14 bg-white/90 backdrop-blur-2xl border-b border-slate-200/40 flex items-center px-6 gap-8 z-[100] flex-shrink-0 relative">
|
||||
{/* Status Indicators (Traffic Lights) */}
|
||||
<div className="flex gap-1.5 opacity-40">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-slate-900" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-slate-900" />
|
||||
</div>
|
||||
|
||||
{/* URL Bar */}
|
||||
<div className="flex-1 max-w-[600px] mx-auto bg-white/30 backdrop-blur-3xl rounded-full flex items-center justify-center px-6 h-8 border border-white/60 shadow-[0_2px_12px_-4px_rgba(0,0,0,0.08)]">
|
||||
<div className="flex items-center gap-3 opacity-80 group-hover:opacity-100 transition-all duration-700">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-slate-900" />
|
||||
<span className="text-[10px] font-mono font-bold tracking-[0.25em] uppercase truncate whitespace-nowrap text-slate-900">
|
||||
{url}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Industrial Accent */}
|
||||
<div className="flex items-center gap-2 opacity-30">
|
||||
<div className="w-8 h-1 bg-slate-400 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 = 0,
|
||||
delay = 0,
|
||||
noScale = false,
|
||||
dynamicGlow = true
|
||||
}) => {
|
||||
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 [isLoading, setIsLoading] = React.useState(true);
|
||||
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 });
|
||||
|
||||
// Scaling Logic
|
||||
React.useEffect(() => {
|
||||
if (!containerRef.current || noScale) {
|
||||
setScale(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateScale = () => {
|
||||
if (containerRef.current) {
|
||||
const currentWidth = containerRef.current.offsetWidth;
|
||||
if (currentWidth > 0) {
|
||||
const newScale = zoom || (currentWidth / desktopWidth);
|
||||
setScale(newScale);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateScale();
|
||||
const observer = new ResizeObserver(updateScale);
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [desktopWidth, zoom, noScale]);
|
||||
|
||||
const updateScrollState = React.useCallback(() => {
|
||||
try {
|
||||
const doc = iframeRef.current?.contentDocument?.documentElement;
|
||||
if (doc) {
|
||||
const atTop = doc.scrollTop <= 5;
|
||||
const atBottom = doc.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]);
|
||||
|
||||
const headerHeightPx = (browserFrame && !minimal) ? 56 : 0;
|
||||
|
||||
// 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 baseNumericHeight = parseNumericHeight(height);
|
||||
const finalScaledHeight = clipHeight
|
||||
? (clipHeight * scale)
|
||||
: (baseNumericHeight ? (baseNumericHeight * scale) : null);
|
||||
|
||||
const chassisStyle = {
|
||||
height: height === '100%'
|
||||
? '100%'
|
||||
: (finalScaledHeight ? `${finalScaledHeight + headerHeightPx}px` : `calc(${height} + ${headerHeightPx}px)`)
|
||||
};
|
||||
|
||||
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 transition-[transform,shadow,scale] duration-1000 ease-[cubic-bezier(0.23,1,0.32,1)] 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",
|
||||
"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 */}
|
||||
<div className="flex-1 relative overflow-hidden bg-slate-50/50">
|
||||
{/* Loader Overlay - Now scoped to viewport */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60 backdrop-blur-xl z-50 transition-opacity duration-700">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-[3px] border-slate-100 border-t-slate-900 rounded-full animate-spin" />
|
||||
<MonoLabel className="text-[10px] text-slate-400 animate-pulse uppercase tracking-[0.2em]">Establishing Connection</MonoLabel>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-1000 ease-[cubic-bezier(0.23,1,0.32,1)] origin-top-left absolute left-0 right-0",
|
||||
noScale && "relative w-full h-full"
|
||||
)}
|
||||
style={{
|
||||
width: noScale ? '100%' : `${desktopWidth}px`,
|
||||
transform: noScale ? 'none' : `scale(${scale})`,
|
||||
height: noScale ? '100%' : `${100 / scale}%`,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
scrolling={allowScroll ? "yes" : "no"}
|
||||
className={cn(
|
||||
"w-full border-none transition-all duration-700 no-scrollbar relative z-0",
|
||||
isLoading ? "opacity-0 scale-95" : "opacity-100 scale-100"
|
||||
)}
|
||||
onLoad={(e) => {
|
||||
setIsLoading(false);
|
||||
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; }
|
||||
body { background: transparent !important; }
|
||||
`;
|
||||
iframe.contentDocument.head.appendChild(style);
|
||||
setTimeout(updateAmbilight, 600);
|
||||
|
||||
const onScroll = () => {
|
||||
requestAnimationFrame(updateAmbilight);
|
||||
updateScrollState();
|
||||
};
|
||||
|
||||
iframe.contentWindow?.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
|
||||
iframe.contentWindow?.addEventListener('wheel', (e) => {
|
||||
const { deltaY } = e as WheelEvent;
|
||||
const doc = iframe.contentDocument?.documentElement;
|
||||
if (!doc) return;
|
||||
const scrollTop = doc.scrollTop;
|
||||
const isAtTop = scrollTop <= 0;
|
||||
const isAtBottom = scrollTop + doc.clientHeight >= doc.scrollHeight - 1;
|
||||
if ((isAtTop && deltaY < 0) || (isAtBottom && deltaY > 0)) {
|
||||
window.scrollBy({ top: deltaY, behavior: 'auto' });
|
||||
}
|
||||
}, { passive: true });
|
||||
} catch (err) { }
|
||||
}}
|
||||
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"}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{!allowScroll && <div className="absolute inset-x-0 bottom-0 top-14 pointer-events-auto cursor-default z-20" />}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
53
apps/web/src/components/InteractiveElements.tsx
Normal file
53
apps/web/src/components/InteractiveElements.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export const InteractiveElements: React.FC = () => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.scrollY;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
|
||||
if (docHeight > 0) {
|
||||
setProgress((scrollTop / docHeight) * 100);
|
||||
}
|
||||
|
||||
setShowBackToTop(scrollTop > 300);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Reading Progress Bar */}
|
||||
<div
|
||||
className="reading-progress-bar"
|
||||
style={{
|
||||
transform: `scaleX(${progress / 100})`,
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Floating Back to Top Button */}
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`floating-back-to-top ${showBackToTop ? 'visible' : ''}`}
|
||||
aria-label="Back to top"
|
||||
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>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
221
apps/web/src/components/Landing/AbstractLines.tsx
Normal file
221
apps/web/src/components/Landing/AbstractLines.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'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> = ({ className = "", delay = 0 }) => null;
|
||||
export const ConnectorBranch: React.FC<LineProps> = ({ className = "", delay = 0 }) => null;
|
||||
export const ConnectorSplit: React.FC<LineProps> = ({ className = "", delay = 0 }) => null;
|
||||
export const ConnectorEnd: React.FC<LineProps> = ({ className = "", delay = 0 }) => null;
|
||||
50
apps/web/src/components/Landing/ComparisonRow.tsx
Normal file
50
apps/web/src/components/Landing/ComparisonRow.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Reveal } from '../Reveal';
|
||||
import { Label, H3, LeadText } from '../Typography';
|
||||
|
||||
interface ComparisonRowProps {
|
||||
negativeLabel: string;
|
||||
negativeText: React.ReactNode;
|
||||
positiveLabel: string;
|
||||
positiveText: React.ReactNode;
|
||||
reverse?: boolean;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export const ComparisonRow: React.FC<ComparisonRowProps> = ({
|
||||
negativeLabel,
|
||||
negativeText,
|
||||
positiveLabel,
|
||||
positiveText,
|
||||
reverse = false,
|
||||
delay = 0,
|
||||
}) => {
|
||||
return (
|
||||
<Reveal delay={delay}>
|
||||
<div className={`flex flex-col ${reverse ? 'md:flex-row-reverse' : 'md:flex-row'} gap-8 md:gap-12 items-center`}>
|
||||
<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-4 line-through decoration-slate-200">
|
||||
{negativeLabel}
|
||||
</Label>
|
||||
<LeadText className="line-through decoration-slate-200 leading-snug">
|
||||
{negativeText}
|
||||
</LeadText>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<ArrowRight className={`w-6 h-6 text-slate-200 hidden md:block ${reverse ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Label className="text-slate-900 mb-4">
|
||||
{positiveLabel}
|
||||
</Label>
|
||||
<H3 className="text-2xl md:text-3xl">
|
||||
{positiveText}
|
||||
</H3>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
3
apps/web/src/components/Landing/ConceptIllustrations.tsx
Normal file
3
apps/web/src/components/Landing/ConceptIllustrations.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
'use client';
|
||||
|
||||
export * from './Illustrations';
|
||||
313
apps/web/src/components/Landing/ExplanatoryIllustrations.tsx
Normal file
313
apps/web/src/components/Landing/ExplanatoryIllustrations.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
'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>
|
||||
);
|
||||
32
apps/web/src/components/Landing/FeatureCard.tsx
Normal file
32
apps/web/src/components/Landing/FeatureCard.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
33
apps/web/src/components/Landing/HeroItem.tsx
Normal file
33
apps/web/src/components/Landing/HeroItem.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const ConceptAutomation: React.FC<IllustrationProps> = ({ className = "", 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>
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
'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>
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
'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>
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const ConceptMessy: 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 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>
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
'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>
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
'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>
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const ConceptSystem: 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="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>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const ConceptTarget: 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="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>
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const ConceptWebsite: 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="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>
|
||||
);
|
||||
@@ -0,0 +1,64 @@
|
||||
'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>
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const HeroArchitecture: React.FC<IllustrationProps> = ({ className = "", 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>
|
||||
);
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className = "", 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
'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>
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
'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>
|
||||
);
|
||||
@@ -0,0 +1,333 @@
|
||||
'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>
|
||||
);
|
||||
16
apps/web/src/components/Landing/Illustrations/index.ts
Normal file
16
apps/web/src/components/Landing/Illustrations/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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';
|
||||
8
apps/web/src/components/Landing/Illustrations/types.ts
Normal file
8
apps/web/src/components/Landing/Illustrations/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
export interface IllustrationProps {
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
259
apps/web/src/components/Landing/ParticleNetwork.tsx
Normal file
259
apps/web/src/components/Landing/ParticleNetwork.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
'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;
|
||||
50
apps/web/src/components/Landing/ServiceCard.tsx
Normal file
50
apps/web/src/components/Landing/ServiceCard.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Reveal } from '../Reveal';
|
||||
|
||||
interface ServiceCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
linkHref?: string;
|
||||
linkLabel?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export const ServiceCard: React.FC<ServiceCardProps> = ({
|
||||
title,
|
||||
description,
|
||||
linkHref,
|
||||
linkLabel,
|
||||
delay = 0,
|
||||
}) => {
|
||||
return (
|
||||
<Reveal delay={delay}>
|
||||
<div className="group relative p-10 md:p-12 border border-slate-100 rounded-3xl hover:border-slate-900 transition-all duration-700 bg-white hover:shadow-2xl hover:shadow-slate-100/50 overflow-hidden">
|
||||
{/* Decorative Corner */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 border-t border-r border-slate-100 rounded-tr-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-700"></div>
|
||||
<div className="absolute top-8 right-8 w-2 h-2 bg-slate-900 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-700 delay-100"></div>
|
||||
|
||||
{/* Decorative background text */}
|
||||
<div className="absolute -right-4 -bottom-4 text-8xl md:text-9xl font-bold text-slate-50 select-none group-hover:text-slate-100 transition-colors duration-700 -z-10">
|
||||
{title.charAt(0)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-4xl md:text-6xl font-bold text-slate-900 mb-6 tracking-tighter group-hover:tracking-tight transition-all duration-700">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-xl md:text-2xl text-slate-500 font-serif italic mb-10 leading-tight max-w-3xl group-hover:text-slate-700 transition-colors duration-700">
|
||||
{description}
|
||||
</p>
|
||||
{linkHref && linkLabel && (
|
||||
<Link
|
||||
href={linkHref}
|
||||
className="inline-flex items-center gap-4 text-slate-900 font-bold text-[10px] uppercase tracking-[0.2em] group/link"
|
||||
>
|
||||
{linkLabel}
|
||||
<div className="w-8 h-px bg-slate-200 group-hover/link:bg-slate-900 group-hover/link:w-16 transition-all duration-500"></div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
10
apps/web/src/components/Landing/index.ts
Normal file
10
apps/web/src/components/Landing/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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';
|
||||
|
||||
68
apps/web/src/components/Layout.tsx
Normal file
68
apps/web/src/components/Layout.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
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 opacity-[0.01]" style={{
|
||||
backgroundImage: 'linear-gradient(#0f172a 1px, transparent 1px), linear-gradient(90deg, #0f172a 1px, transparent 1px)',
|
||||
backgroundSize: '60px 60px'
|
||||
}} />
|
||||
);
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: 'white' | 'dark' | 'gray';
|
||||
hover?: boolean;
|
||||
padding?: 'none' | 'small' | 'normal' | 'large';
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
className = "",
|
||||
variant = 'white',
|
||||
hover = true,
|
||||
padding = 'normal'
|
||||
}) => {
|
||||
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'
|
||||
};
|
||||
|
||||
const paddings = {
|
||||
none: 'p-0',
|
||||
small: 'p-6 md:p-8',
|
||||
normal: 'p-8 md:p-10',
|
||||
large: 'p-10 md:p-12'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-2xl border h-full flex flex-col justify-between transition-all duration-500 ease-out",
|
||||
variants[variant],
|
||||
paddings[padding],
|
||||
hover ? 'hover:border-slate-200 hover:shadow-md' : '',
|
||||
className
|
||||
)}>
|
||||
{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-6 w-full", variants[variant], className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
47
apps/web/src/components/MediumCard.tsx
Normal file
47
apps/web/src/components/MediumCard.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Post {
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
slug: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface MediumCardProps {
|
||||
post: Post;
|
||||
}
|
||||
|
||||
export const MediumCard: React.FC<MediumCardProps> = ({ post }) => {
|
||||
const { title, description, date, slug } = post;
|
||||
|
||||
const formattedDate = new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<Link href={`/blog/${slug}`} className="group block">
|
||||
<article className="space-y-4 py-8 px-8 border border-slate-200 rounded-3xl group-hover:border-slate-400 transition-all duration-500 bg-white">
|
||||
<time className="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-400 group-hover:text-slate-900 transition-colors">
|
||||
{formattedDate}
|
||||
</time>
|
||||
|
||||
<h3 className="text-3xl md:text-4xl font-bold text-slate-900 tracking-tight group-hover:text-slate-900 transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p className="text-xl text-slate-500 font-serif italic leading-snug line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="pt-4 flex items-center gap-4 text-slate-900 font-bold text-xs uppercase tracking-widest group/link">
|
||||
Lesen
|
||||
<div className="w-10 h-px bg-slate-200 group-hover:bg-slate-400 group-hover:w-16 transition-all duration-500"></div>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
87
apps/web/src/components/Mermaid.tsx
Normal file
87
apps/web/src/components/Mermaid.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
|
||||
interface MermaidProps {
|
||||
graph: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const Mermaid: React.FC<MermaidProps> = ({ graph, id: providedId }) => {
|
||||
const [id, setId] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setId(providedId || `mermaid-${Math.random().toString(36).substring(2, 11)}`);
|
||||
}, [providedId]);
|
||||
const [isRendered, setIsRendered] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'default',
|
||||
darkMode: false,
|
||||
themeVariables: {
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontSize: '16px',
|
||||
primaryColor: '#ffffff',
|
||||
nodeBorder: '#e2e8f0',
|
||||
mainBkg: '#ffffff',
|
||||
lineColor: '#cbd5e1',
|
||||
},
|
||||
securityLevel: 'loose',
|
||||
});
|
||||
|
||||
const render = async () => {
|
||||
if (containerRef.current && id) {
|
||||
try {
|
||||
const { svg } = await mermaid.render(`${id}-svg`, graph);
|
||||
containerRef.current.innerHTML = svg;
|
||||
setIsRendered(true);
|
||||
} catch (err) {
|
||||
console.error('Mermaid rendering failed:', err);
|
||||
setError('Failed to render diagram. Please check the syntax.');
|
||||
setIsRendered(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (id) {
|
||||
render();
|
||||
}
|
||||
}, [graph, id]);
|
||||
|
||||
if (!id) return null;
|
||||
|
||||
return (
|
||||
<div className="mermaid-wrapper my-8 not-prose">
|
||||
<div className="bg-white border border-slate-200 rounded-2xl overflow-hidden transition-all duration-500 hover:border-slate-400">
|
||||
<div className="px-4 py-3 border-b border-slate-100 bg-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em]">Diagram</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mermaid-container p-8 md:p-12 overflow-x-auto flex justify-center bg-white">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`mermaid transition-opacity duration-500 w-full max-w-4xl ${isRendered ? 'opacity-100' : 'opacity-0'}`}
|
||||
id={id}
|
||||
>
|
||||
{error ? (
|
||||
<div className="text-red-500 p-4 border border-red-200 rounded bg-red-50 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
graph
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
60
apps/web/src/components/Modal.tsx
Normal file
60
apps/web/src/components/Modal.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
// Close on escape key
|
||||
React.useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[100]"
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4 z-[101] pointer-events-none">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="bg-white w-full max-w-lg rounded-[2.5rem] shadow-2xl pointer-events-auto overflow-hidden"
|
||||
>
|
||||
<div className="p-8 border-b border-slate-50 flex items-center justify-between">
|
||||
<h3 className="text-2xl font-bold text-slate-900">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-50 rounded-full transition-colors text-slate-400 hover:text-slate-900"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-8">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
60
apps/web/src/components/PageHeader.tsx
Normal file
60
apps/web/src/components/PageHeader.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Reveal } from './Reveal';
|
||||
import { H1, LeadText } from './Typography';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: React.ReactNode;
|
||||
description?: string;
|
||||
backLink?: {
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
backgroundSymbol?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
title,
|
||||
description,
|
||||
backLink,
|
||||
backgroundSymbol,
|
||||
className = ""
|
||||
}) => {
|
||||
return (
|
||||
<section className={cn("narrow-container relative pt-24 pb-16 md:pt-40 md:pb-24", className)}>
|
||||
{backgroundSymbol && (
|
||||
<div className="absolute -left-24 -top-12 text-[20rem] md:text-[24rem] font-bold text-slate-50 select-none -z-10 opacity-40 tracking-tighter leading-none">
|
||||
{backgroundSymbol}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backLink && (
|
||||
<Link
|
||||
href={backLink.href}
|
||||
className="inline-flex items-center gap-2 text-slate-400 hover:text-slate-900 mb-12 transition-colors font-bold text-[10px] uppercase tracking-[0.4em] group"
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3 group-hover:-translate-x-1 transition-transform" /> {backLink.label}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="space-y-8 relative">
|
||||
<Reveal>
|
||||
<H1 className="max-w-4xl">
|
||||
{title}
|
||||
</H1>
|
||||
</Reveal>
|
||||
|
||||
{description && (
|
||||
<Reveal delay={0.2}>
|
||||
<LeadText className="text-xl md:text-2xl text-slate-400 leading-relaxed max-w-2xl font-serif italic">
|
||||
{description}
|
||||
</LeadText>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
92
apps/web/src/components/Reveal.tsx
Normal file
92
apps/web/src/components/Reveal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { motion, useInView, useAnimation, Variants } from 'framer-motion';
|
||||
|
||||
interface RevealProps {
|
||||
children: React.ReactNode;
|
||||
width?: 'fit-content' | '100%';
|
||||
delay?: number;
|
||||
className?: string;
|
||||
direction?: 'up' | 'down' | 'left' | 'right' | 'none';
|
||||
scale?: number;
|
||||
blur?: boolean;
|
||||
}
|
||||
|
||||
export const Reveal: React.FC<RevealProps> = ({
|
||||
children,
|
||||
width = 'fit-content',
|
||||
delay = 0.25,
|
||||
className = "",
|
||||
direction = 'up',
|
||||
scale = 1,
|
||||
blur = false
|
||||
}) => {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-10%" });
|
||||
const mainControls = useAnimation();
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView) {
|
||||
mainControls.start("visible");
|
||||
}
|
||||
}, [isInView, mainControls]);
|
||||
|
||||
const getDirectionOffset = () => {
|
||||
switch (direction) {
|
||||
case 'up': return { y: 20 };
|
||||
case 'down': return { y: -20 };
|
||||
case 'left': return { x: 20 };
|
||||
case 'right': return { x: -20 };
|
||||
default: return {};
|
||||
}
|
||||
};
|
||||
|
||||
const variants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
...getDirectionOffset(),
|
||||
scale: scale !== 1 ? scale : 0.99,
|
||||
rotateX: direction === 'up' ? 2 : direction === 'down' ? -2 : 0,
|
||||
rotateY: direction === 'left' ? -2 : direction === 'right' ? 2 : 0,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
x: 0,
|
||||
scale: 1,
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
position: "relative",
|
||||
width,
|
||||
perspective: "1000px"
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
<motion.div
|
||||
variants={variants}
|
||||
initial="hidden"
|
||||
animate={mainControls}
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
delay: delay,
|
||||
type: "spring",
|
||||
stiffness: 70,
|
||||
damping: 24,
|
||||
mass: 1,
|
||||
opacity: { duration: 0.5, ease: [0.16, 1, 0.3, 1] }
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
78
apps/web/src/components/SearchBar.tsx
Normal file
78
apps/web/src/components/SearchBar.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
|
||||
interface SearchBarProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
size?: 'small' | 'large';
|
||||
}
|
||||
|
||||
export const SearchBar: React.FC<SearchBarProps> = ({ value: propValue, onChange, size = 'small' }) => {
|
||||
const [internalValue, setInternalValue] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const value = propValue !== undefined ? propValue : internalValue;
|
||||
|
||||
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
} else {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
if (onChange) {
|
||||
onChange('');
|
||||
} else {
|
||||
setInternalValue('');
|
||||
}
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
clearSearch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={value}
|
||||
className={`w-full px-8 py-4 font-bold text-slate-900 bg-white rounded-2xl border transition-all focus:outline-none placeholder:text-slate-300 ${
|
||||
size === 'large' ? 'text-2xl md:text-4xl py-6 rounded-3xl' : 'text-lg'
|
||||
} ${
|
||||
isFocused ? 'border-slate-400' : 'border-slate-200'
|
||||
}`}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
aria-label="Search blog posts"
|
||||
/>
|
||||
|
||||
{value && (
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 px-3 py-1 border border-slate-200 rounded-full text-[10px] font-bold uppercase tracking-widest text-slate-400 hover:text-slate-900 hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-[calc(50%+2px)] hover:shadow-lg hover:shadow-slate-100"
|
||||
aria-label="Clear search"
|
||||
type="button"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
83
apps/web/src/components/Section.tsx
Normal file
83
apps/web/src/components/Section.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react';
|
||||
import { Reveal } from './Reveal';
|
||||
import { Label } from './Typography';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface SectionProps {
|
||||
number?: string;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
variant?: 'white' | 'gray';
|
||||
borderTop?: boolean;
|
||||
borderBottom?: boolean;
|
||||
containerVariant?: 'narrow' | 'normal' | 'wide';
|
||||
illustration?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Section: React.FC<SectionProps> = ({
|
||||
number,
|
||||
title,
|
||||
children,
|
||||
className = "",
|
||||
delay = 0,
|
||||
variant = 'white',
|
||||
borderTop = false,
|
||||
borderBottom = false,
|
||||
containerVariant = 'narrow',
|
||||
illustration,
|
||||
}) => {
|
||||
const bgClass = variant === 'gray' ? 'bg-slate-50/50' : 'bg-white';
|
||||
const borderTopClass = borderTop ? 'border-t border-slate-100' : '';
|
||||
const borderBottomClass = borderBottom ? 'border-b border-slate-100' : '';
|
||||
const containerClass = containerVariant === 'wide' ? 'wide-container' : containerVariant === 'normal' ? 'container' : 'narrow-container';
|
||||
|
||||
return (
|
||||
<section className={cn(
|
||||
"relative py-24 md:py-40 group overflow-hidden",
|
||||
bgClass,
|
||||
borderTopClass,
|
||||
borderBottomClass,
|
||||
className
|
||||
)}>
|
||||
<div className={cn("relative z-10", containerClass)}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-12 md:gap-24">
|
||||
{/* Sidebar: Number & Title */}
|
||||
<div className="md:col-span-3">
|
||||
<div className="md:sticky md:top-40 space-y-8">
|
||||
{number && (
|
||||
<Reveal delay={delay}>
|
||||
<span className="block text-7xl md:text-8xl font-bold text-slate-100 leading-none select-none tracking-tighter">
|
||||
{number}
|
||||
</span>
|
||||
</Reveal>
|
||||
)}
|
||||
{title && (
|
||||
<Reveal delay={delay + 0.1}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Label className="text-slate-900 text-[10px] tracking-[0.4em]">
|
||||
{title}
|
||||
</Label>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
{illustration && (
|
||||
<Reveal delay={delay + 0.2}>
|
||||
<div className="pt-8 opacity-100 group-hover:scale-105 transition-transform duration-1000 ease-out origin-left grayscale hover:grayscale-0">
|
||||
{illustration}
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="md:col-span-9">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
81
apps/web/src/components/ShareModal.tsx
Normal file
81
apps/web/src/components/ShareModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { Copy, Check, Share2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ShareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
url: string;
|
||||
qrCodeData?: string;
|
||||
}
|
||||
|
||||
export function ShareModal({ isOpen, onClose, url, qrCodeData }: ShareModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleNativeShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: 'Meine Projekt-Konfiguration',
|
||||
url: url
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Share failed", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Konfiguration teilen">
|
||||
<div className="space-y-8">
|
||||
<p className="text-slate-500 leading-relaxed">
|
||||
Speichern Sie diesen Link, um Ihre Konfiguration später fortzusetzen oder teilen Sie ihn mit anderen.
|
||||
</p>
|
||||
|
||||
{qrCodeData && (
|
||||
<div className="flex flex-col items-center gap-4 p-8 bg-slate-50 rounded-[2rem]">
|
||||
<img src={qrCodeData} alt="QR Code" className="w-48 h-48 rounded-xl shadow-sm" />
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">QR-Code scannen</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={url}
|
||||
className="w-full p-6 pr-20 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-mono text-slate-600 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute right-2 top-2 bottom-2 px-4 bg-white border border-slate-100 rounded-xl text-slate-900 hover:bg-slate-900 hover:text-white transition-all flex items-center gap-2"
|
||||
>
|
||||
{copied ? <Check size={18} /> : <Copy size={18} />}
|
||||
<span className="text-xs font-bold uppercase tracking-wider">{copied ? 'Kopiert' : 'Kopieren'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{typeof navigator !== 'undefined' && !!navigator.share && (
|
||||
<button
|
||||
onClick={handleNativeShare}
|
||||
className="w-full p-6 bg-slate-900 text-white rounded-2xl font-bold flex items-center justify-center gap-3 hover:bg-slate-800 transition-all"
|
||||
>
|
||||
<Share2 size={20} />
|
||||
<span>System-Dialog öffnen</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
19
apps/web/src/components/Tag.tsx
Normal file
19
apps/web/src/components/Tag.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface TagProps {
|
||||
tag: string;
|
||||
index: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Tag: React.FC<TagProps> = ({ tag, className = '' }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/tags/${tag}`}
|
||||
className={`inline-block text-[10px] font-bold uppercase tracking-[0.2em] text-slate-500 bg-white border border-slate-200 px-4 py-2 rounded-full hover:border-slate-400 hover:text-slate-900 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100 ${className}`}
|
||||
>
|
||||
{tag}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
51
apps/web/src/components/TwitterEmbed.tsx
Normal file
51
apps/web/src/components/TwitterEmbed.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as React from 'react';
|
||||
|
||||
interface TwitterEmbedProps {
|
||||
tweetId: string;
|
||||
theme?: 'light' | 'dark';
|
||||
className?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
export async function TwitterEmbed({
|
||||
tweetId,
|
||||
theme = 'light',
|
||||
className = "",
|
||||
align = 'center'
|
||||
}: TwitterEmbedProps) {
|
||||
let embedHtml = '';
|
||||
|
||||
try {
|
||||
const oEmbedUrl = `https://publish.twitter.com/oembed?url=https://twitter.com/twitter/status/${tweetId}&theme=${theme}`;
|
||||
const response = await fetch(oEmbedUrl);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
embedHtml = data.html || '';
|
||||
} else {
|
||||
console.warn(`Twitter oEmbed failed for tweet ${tweetId}: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch Twitter embed for ${tweetId}:`, error);
|
||||
}
|
||||
|
||||
const alignmentClass = align === 'left' ? 'mr-auto ml-0' : align === 'right' ? 'ml-auto mr-0' : 'mx-auto';
|
||||
|
||||
return (
|
||||
<div className={`not-prose ${className} ${alignmentClass} w-4/5`} data-theme={theme} data-align={align}>
|
||||
{embedHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: embedHtml }} />
|
||||
) : (
|
||||
<div className="p-6 bg-slate-50 border border-dashed border-slate-200 rounded-lg text-center text-slate-600 text-sm flex flex-col items-center gap-2">
|
||||
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M23 3a10.9 10.9 0 01-3.14 1.53 4.48 4.48 0 00-7.86 3v1A10.66 10.66 0 013 4s-4 9 5 13a11.64 11.64 0 01-7 2c9 5 20 0 20-11.5a4.5 4.5 0 00-.08-.83A7.72 7.72 0 0023 3z"/>
|
||||
</svg>
|
||||
<span>Unable to load tweet</span>
|
||||
<a href={`https://twitter.com/i/status/${tweetId}`} target="_blank" rel="noopener noreferrer" className="text-slate-600 hover:text-slate-900 font-medium text-sm">
|
||||
View on Twitter →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
apps/web/src/components/Typography.tsx
Normal file
54
apps/web/src/components/Typography.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
|
||||
interface TypographyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const H1: React.FC<TypographyProps> = ({ children, className = "" }) => (
|
||||
<h1 className={`text-5xl md:text-7xl font-bold text-slate-900 tracking-tighter leading-[1.1] ${className}`}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
|
||||
export const H2: React.FC<TypographyProps> = ({ children, className = "" }) => (
|
||||
<h2 className={`text-3xl md:text-5xl font-bold text-slate-900 tracking-tighter leading-tight ${className}`}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
|
||||
export const H3: React.FC<TypographyProps> = ({ children, className = "" }) => (
|
||||
<h3 className={`text-2xl md:text-4xl font-bold text-slate-900 tracking-tight leading-tight ${className}`}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export const H4: React.FC<TypographyProps> = ({ children, className = "" }) => (
|
||||
<h4 className={`text-xl md:text-2xl font-bold text-slate-900 tracking-tight ${className}`}>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
|
||||
export const LeadText: React.FC<TypographyProps> = ({ children, className = "" }) => (
|
||||
<p className={`text-lg md:text-xl font-serif italic text-slate-500 leading-relaxed ${className}`}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
export const BodyText: React.FC<TypographyProps> = ({ children, className = "" }) => (
|
||||
<p className={`text-base text-slate-500 leading-relaxed ${className}`}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
export const Label: React.FC<TypographyProps> = ({ children, className = "" }) => (
|
||||
<span className={`text-[10px] font-bold uppercase tracking-[0.3em] text-slate-400 block ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const MonoLabel: React.FC<TypographyProps> = ({ children, className = "" }) => (
|
||||
<span className={`text-[10px] font-mono uppercase tracking-[0.2em] text-slate-400 block ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
42
apps/web/src/components/YouTubeEmbed.tsx
Normal file
42
apps/web/src/components/YouTubeEmbed.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
|
||||
interface YouTubeEmbedProps {
|
||||
videoId: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
aspectRatio?: string;
|
||||
style?: 'default' | 'minimal' | 'rounded' | 'flat';
|
||||
}
|
||||
|
||||
export const YouTubeEmbed: React.FC<YouTubeEmbedProps> = ({
|
||||
videoId,
|
||||
title = "YouTube Video",
|
||||
className = "",
|
||||
aspectRatio = "56.25%",
|
||||
style = "default"
|
||||
}) => {
|
||||
const embedUrl = `https://www.youtube.com/embed/${videoId}`;
|
||||
|
||||
return (
|
||||
<div className={`not-prose my-6 ${className}`} data-style={style}>
|
||||
<div
|
||||
className="bg-white border border-slate-200/80 rounded-xl overflow-hidden transition-all duration-200 hover:border-slate-300/80 p-1"
|
||||
style={{
|
||||
paddingBottom: `calc(${aspectRatio} - 0.5rem)`,
|
||||
position: 'relative',
|
||||
height: 0,
|
||||
marginTop: '0.25rem',
|
||||
marginBottom: '0.25rem'
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title={title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="absolute top-1 left-1 w-[calc(100%-0.5rem)] h-[calc(100%-0.5rem)] border-none rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
55
apps/web/src/components/pdf/DINLayout.tsx
Normal file
55
apps/web/src/components/pdf/DINLayout.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Page as PDFPage } from '@react-pdf/renderer';
|
||||
import { FoldingMarks, Header, Footer, pdfStyles } from './SharedUI';
|
||||
|
||||
interface DINLayoutProps {
|
||||
children: React.ReactNode;
|
||||
sender?: string;
|
||||
recipient?: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
taxId?: string;
|
||||
};
|
||||
icon?: string;
|
||||
footerLogo?: string;
|
||||
companyData: any;
|
||||
bankData: any;
|
||||
showAddress?: boolean;
|
||||
showFooterDetails?: boolean;
|
||||
}
|
||||
|
||||
export const DINLayout = ({
|
||||
children,
|
||||
sender,
|
||||
recipient,
|
||||
icon,
|
||||
footerLogo,
|
||||
companyData,
|
||||
bankData,
|
||||
showAddress = true,
|
||||
showFooterDetails = true
|
||||
}: DINLayoutProps) => {
|
||||
return (
|
||||
<PDFPage size="A4" style={pdfStyles.page}>
|
||||
<FoldingMarks />
|
||||
<Header
|
||||
sender={sender}
|
||||
recipient={recipient}
|
||||
icon={icon}
|
||||
showAddress={showAddress}
|
||||
/>
|
||||
{children}
|
||||
<Footer
|
||||
logo={footerLogo}
|
||||
companyData={companyData}
|
||||
bankData={bankData}
|
||||
showDetails={showFooterDetails}
|
||||
/>
|
||||
</PDFPage>
|
||||
);
|
||||
};
|
||||
304
apps/web/src/components/pdf/SharedUI.tsx
Normal file
304
apps/web/src/components/pdf/SharedUI.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { View as PDFView, Text as PDFText, StyleSheet, Link as PDFLink, Image as PDFImage, Font } from '@react-pdf/renderer';
|
||||
|
||||
// INDUSTRIAL DESIGN SYSTEM TOKENS
|
||||
export const COLORS = {
|
||||
CHARCOAL: '#0f172a', // Slate 900
|
||||
TEXT_MAIN: '#334155', // Slate 700
|
||||
TEXT_DIM: '#64748b', // Slate 500
|
||||
TEXT_LIGHT: '#94a3b8', // Slate 400
|
||||
DIVIDER: '#cbd5e1', // Slate 300
|
||||
GRID: '#f1f5f9', // Slate 100
|
||||
BLUEPRINT: '#e2e8f0', // Slate 200
|
||||
WHITE: '#ffffff'
|
||||
};
|
||||
|
||||
export const FONT_SIZES = {
|
||||
H1: 28,
|
||||
H2: 20,
|
||||
H3: 14,
|
||||
BODY: 11,
|
||||
TINY: 9,
|
||||
SUB: 10,
|
||||
BLUEPRINT: 8
|
||||
};
|
||||
|
||||
// Register a more technical font if possible, or use Helvetica with varying weights
|
||||
// Note: helvetica-bold is standard in react-pdf
|
||||
|
||||
export const pdfStyles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 45, // DIN 5008
|
||||
paddingLeft: 70, // ~25mm
|
||||
paddingRight: 57, // ~20mm
|
||||
paddingBottom: 80, // Safe buffer for absolute footer
|
||||
backgroundColor: COLORS.WHITE,
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
titlePage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: COLORS.WHITE,
|
||||
fontFamily: 'Helvetica',
|
||||
color: COLORS.CHARCOAL,
|
||||
padding: 0, // NO PADDING to prevent inner overflow page breaks
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 20,
|
||||
minHeight: 120,
|
||||
},
|
||||
addressBlock: {
|
||||
width: '55%',
|
||||
marginTop: 45, // DIN 5008 positioning for window
|
||||
},
|
||||
senderLine: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
textDecoration: 'underline',
|
||||
color: COLORS.TEXT_DIM,
|
||||
marginBottom: 8,
|
||||
},
|
||||
recipientAddress: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
brandLogoContainer: {
|
||||
width: '40%',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
brandIconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
backgroundColor: '#0f172a',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
brandIconText: {
|
||||
color: COLORS.WHITE,
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
titleInfo: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
mainTitle: {
|
||||
fontSize: FONT_SIZES.H3,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
color: COLORS.CHARCOAL,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
subTitle: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_DIM,
|
||||
marginTop: 2,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: FONT_SIZES.SUB,
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
marginBottom: 8,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 32,
|
||||
left: 70,
|
||||
right: 57,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: COLORS.GRID,
|
||||
paddingTop: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
footerColumn: {
|
||||
flex: 1,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
footerLogo: {
|
||||
height: 20,
|
||||
width: 'auto',
|
||||
objectFit: 'contain',
|
||||
marginBottom: 8,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
footerLabel: {
|
||||
fontWeight: 'bold',
|
||||
color: COLORS.TEXT_DIM,
|
||||
},
|
||||
pageNumber: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.DIVIDER,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 8,
|
||||
textAlign: 'right',
|
||||
},
|
||||
foldingMark: {
|
||||
position: 'absolute',
|
||||
left: 20,
|
||||
width: 10,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: COLORS.DIVIDER,
|
||||
},
|
||||
divider: {
|
||||
width: '100%',
|
||||
height: 1,
|
||||
backgroundColor: COLORS.DIVIDER,
|
||||
marginVertical: 12,
|
||||
},
|
||||
blueprintGrid: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: -10,
|
||||
},
|
||||
gridLineH: {
|
||||
width: '100%',
|
||||
height: 0.5,
|
||||
backgroundColor: COLORS.GRID,
|
||||
position: 'absolute',
|
||||
},
|
||||
gridLineV: {
|
||||
width: 0.5,
|
||||
height: '100%',
|
||||
backgroundColor: COLORS.GRID,
|
||||
position: 'absolute',
|
||||
},
|
||||
technicalMarker: {
|
||||
position: 'absolute',
|
||||
fontSize: FONT_SIZES.BLUEPRINT,
|
||||
color: COLORS.BLUEPRINT,
|
||||
fontFamily: 'Helvetica',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
// Atoms
|
||||
industrialListItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 6,
|
||||
},
|
||||
industrialBulletBox: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
backgroundColor: COLORS.DIVIDER,
|
||||
marginRight: 8,
|
||||
marginTop: 5,
|
||||
},
|
||||
industrialTitle: {
|
||||
fontSize: FONT_SIZES.H1,
|
||||
fontWeight: 'bold',
|
||||
color: COLORS.CHARCOAL,
|
||||
marginBottom: 6,
|
||||
letterSpacing: 0, // Reset for clarity
|
||||
},
|
||||
industrialSubtitle: {
|
||||
fontSize: FONT_SIZES.SUB,
|
||||
fontWeight: 'bold',
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 16,
|
||||
letterSpacing: 2,
|
||||
},
|
||||
industrialTextLead: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_MAIN,
|
||||
lineHeight: 1.6,
|
||||
marginBottom: 12,
|
||||
},
|
||||
industrialText: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.6,
|
||||
marginBottom: 8,
|
||||
},
|
||||
industrialCard: {
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.BLUEPRINT,
|
||||
marginBottom: 12,
|
||||
},
|
||||
industrialCardTitle: {
|
||||
fontSize: FONT_SIZES.BODY + 1, // 10
|
||||
fontWeight: 'bold',
|
||||
color: COLORS.CHARCOAL,
|
||||
marginBottom: 4,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
darkBox: {
|
||||
marginTop: 32,
|
||||
padding: 24,
|
||||
backgroundColor: COLORS.CHARCOAL,
|
||||
color: COLORS.WHITE,
|
||||
},
|
||||
darkTitle: {
|
||||
fontSize: FONT_SIZES.H2,
|
||||
fontWeight: 'bold',
|
||||
color: COLORS.WHITE,
|
||||
marginBottom: 8,
|
||||
},
|
||||
darkText: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
});
|
||||
|
||||
const styles = pdfStyles;
|
||||
|
||||
export const BlueprintBackground = () => (
|
||||
<PDFView style={styles.blueprintGrid} fixed>
|
||||
{/* Clean background - grid lines removed per user request */}
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const IndustrialListItem = ({ children }: { children: React.ReactNode }) => (
|
||||
<PDFView style={pdfStyles.industrialListItem}>
|
||||
<PDFView style={pdfStyles.industrialBulletBox} />
|
||||
{children}
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const IndustrialCard = ({ title, children, style = {} }: { title: string; children: React.ReactNode; style?: any }) => (
|
||||
<PDFView style={[pdfStyles.industrialCard, style]}>
|
||||
<PDFText style={pdfStyles.industrialCardTitle}>{title}</PDFText>
|
||||
{children}
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const FoldingMarks = () => (<><PDFView style={[pdfStyles.foldingMark, { top: 297.6 }]} fixed /><PDFView style={[pdfStyles.foldingMark, { top: 420.9, width: 15 }]} fixed /><PDFView style={[pdfStyles.foldingMark, { top: 595.3 }]} fixed /></>);
|
||||
|
||||
export const Divider = ({ style = {} }: { style?: any }) => (
|
||||
<PDFView style={[pdfStyles.divider, style]} />
|
||||
);
|
||||
|
||||
export const Footer = ({ logo, companyData, bankData, showDetails = true, showPageNumber = true }: { logo?: string; companyData: any; bankData: any; showDetails?: boolean; showPageNumber?: boolean }) => (
|
||||
<PDFView style={pdfStyles.footer}><PDFView style={pdfStyles.footerColumn}>{logo ? (<PDFImage src={logo} style={pdfStyles.footerLogo} />) : (<PDFText style={{ fontSize: 12, fontWeight: 'bold', marginBottom: 8 }}>marc mintel</PDFText>)}</PDFView>{showDetails && (<><PDFView style={pdfStyles.footerColumn}><PDFText style={pdfStyles.footerText}><PDFText style={pdfStyles.footerLabel}>{companyData.name}</PDFText>{"\n"}{companyData.address1}{"\n"}{companyData.address2}{"\n"}UST: {companyData.ustId}</PDFText></PDFView><PDFView style={[pdfStyles.footerColumn, { alignItems: 'flex-end' }]}>{showPageNumber && <PDFText style={pdfStyles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed />}</PDFView></>)}{!showDetails && (<PDFView style={[pdfStyles.footerColumn, { alignItems: 'flex-end' }]}>{showPageNumber && <PDFText style={pdfStyles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed />}</PDFView>)}</PDFView>
|
||||
);
|
||||
|
||||
export const Header = ({ sender, recipient, icon, showAddress = true }: { sender?: string; recipient?: { title: string; subtitle?: string; email?: string; address?: string; phone?: string; taxId?: string }; icon?: string; showAddress?: boolean; }) => (
|
||||
<PDFView style={[pdfStyles.header, showAddress ? {} : { minHeight: 40, marginBottom: 0 }]}><PDFView style={pdfStyles.addressBlock}>{showAddress && sender && (<><PDFText style={pdfStyles.senderLine}>{sender}</PDFText>{recipient && (<PDFView style={pdfStyles.recipientAddress}><PDFText style={{ fontWeight: 'bold' }}>{recipient.title}</PDFText>{recipient.subtitle && <PDFText>{recipient.subtitle}</PDFText>}{recipient.address && <PDFText>{recipient.address}</PDFText>}{recipient.phone && <PDFText>{recipient.phone}</PDFText>}{recipient.email && <PDFText>{recipient.email}</PDFText>}{recipient.taxId && <PDFText>USt-ID: {recipient.taxId}</PDFText>}</PDFView>)}</>)}</PDFView><PDFView style={pdfStyles.brandLogoContainer}><PDFView style={pdfStyles.brandIconContainer}>{icon ? (<PDFImage src={icon} style={{ width: 24, height: 24 }} />) : (<PDFText style={pdfStyles.brandIconText}>M</PDFText>)}</PDFView></PDFView></PDFView>
|
||||
);
|
||||
|
||||
export const DocumentTitle = ({ title, subLines }: { title: string; subLines?: string[] }) => (
|
||||
<PDFView style={pdfStyles.titleInfo}><PDFText style={pdfStyles.mainTitle}>{title}</PDFText>{subLines?.map((line, i) => (<PDFText key={i} style={[pdfStyles.subTitle, i === 1 ? { fontWeight: 'bold', color: '#0f172a' } : {}]}>{line}</PDFText>))}</PDFView>
|
||||
);
|
||||
68
apps/web/src/components/pdf/SimpleLayout.tsx
Normal file
68
apps/web/src/components/pdf/SimpleLayout.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Page as PDFPage, View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer';
|
||||
import { Header, Footer, pdfStyles, BlueprintBackground } from './SharedUI';
|
||||
|
||||
const simpleStyles = StyleSheet.create({
|
||||
industrialPage: {
|
||||
padding: 30,
|
||||
paddingTop: 20,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
industrialNumber: {
|
||||
fontSize: 60,
|
||||
fontWeight: 'bold',
|
||||
color: '#f1f5f9',
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
right: 0,
|
||||
zIndex: -1,
|
||||
},
|
||||
industrialSection: {
|
||||
marginTop: 16,
|
||||
paddingTop: 12,
|
||||
flexDirection: 'row',
|
||||
position: 'relative',
|
||||
},
|
||||
});
|
||||
|
||||
interface SimpleLayoutProps {
|
||||
children: React.ReactNode;
|
||||
pageNumber?: string;
|
||||
icon?: string;
|
||||
footerLogo?: string;
|
||||
companyData: any;
|
||||
bankData: any;
|
||||
showPageNumber?: boolean;
|
||||
}
|
||||
|
||||
export const SimpleLayout = ({
|
||||
children,
|
||||
pageNumber,
|
||||
icon,
|
||||
footerLogo,
|
||||
companyData,
|
||||
bankData,
|
||||
showPageNumber = true
|
||||
}: SimpleLayoutProps) => {
|
||||
return (
|
||||
<PDFPage size="A4" style={[pdfStyles.page, simpleStyles.industrialPage]}>
|
||||
<BlueprintBackground />
|
||||
<Header icon={icon} showAddress={false} />
|
||||
{pageNumber && <PDFText style={simpleStyles.industrialNumber}>{pageNumber}</PDFText>}
|
||||
<PDFView style={simpleStyles.industrialSection}>
|
||||
<PDFView style={{ width: '100%' }}>
|
||||
{children}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
<Footer
|
||||
logo={footerLogo}
|
||||
companyData={companyData}
|
||||
bankData={bankData}
|
||||
showDetails={false}
|
||||
showPageNumber={showPageNumber}
|
||||
/>
|
||||
</PDFPage>
|
||||
);
|
||||
};
|
||||
108
apps/web/src/components/pdf/modules/BrandingModules.tsx
Normal file
108
apps/web/src/components/pdf/modules/BrandingModules.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer';
|
||||
import { IndustrialListItem, IndustrialCard, Divider, COLORS, FONT_SIZES } from '../SharedUI';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
industrialTitle: { fontSize: FONT_SIZES.H1, fontWeight: 'bold', color: COLORS.CHARCOAL, marginBottom: 6, letterSpacing: -1 },
|
||||
industrialSubtitle: { fontSize: FONT_SIZES.SUB, fontWeight: 'bold', color: COLORS.TEXT_LIGHT, marginBottom: 16, letterSpacing: 0.5 },
|
||||
industrialTextLead: { fontSize: FONT_SIZES.H3, color: COLORS.TEXT_MAIN, lineHeight: 1.6, marginBottom: 16 },
|
||||
industrialText: { fontSize: FONT_SIZES.BODY, color: COLORS.TEXT_DIM, lineHeight: 1.6, marginBottom: 12 },
|
||||
industrialGrid2: { flexDirection: 'row' },
|
||||
industrialCol: { width: '46%' },
|
||||
industrialBulletBox: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
backgroundColor: COLORS.DIVIDER,
|
||||
marginRight: 8,
|
||||
marginTop: 5,
|
||||
},
|
||||
});
|
||||
|
||||
export const AboutModule = () => (
|
||||
<>
|
||||
<PDFText style={styles.industrialTitle}>Expertise & Profil</PDFText>
|
||||
<PDFText style={styles.industrialSubtitle}>Entwicklung & Technischer Partner für den Mittelstand</PDFText>
|
||||
<Divider style={{ marginVertical: 16, backgroundColor: COLORS.GRID }} />
|
||||
|
||||
<PDFView style={{ marginTop: 24 }}>
|
||||
<PDFText style={styles.industrialTextLead}>
|
||||
Begleitung mittelständischer Unternehmen und Agenturen bei der Realisierung anspruchsvoller Web-Projekte. Als Senior Software Developer mit über 15 Jahren Erfahrung wird das gesamte technische Spektrum abgedeckt – von der Architektur bis zum fertigen Produkt.
|
||||
</PDFText>
|
||||
|
||||
<PDFView style={[styles.industrialGrid2, { marginTop: 20 }]}>
|
||||
<PDFView style={[styles.industrialCol, { marginRight: '8%' }]}>
|
||||
<PDFText style={[styles.industrialText, { fontWeight: 'bold', color: COLORS.CHARCOAL, marginBottom: 8 }]}>Erfahrung & Substanz</PDFText>
|
||||
<PDFText style={styles.industrialText}>
|
||||
Der Werdegang umfasst alle Ebenen der Webentwicklung: von der Teamleitung in Kreativagenturen bis zur Softwareentwicklung für internationale Konzerne.
|
||||
</PDFText>
|
||||
<PDFText style={styles.industrialText}>
|
||||
Die Kenntnis komplexer Enterprise-Systeme wird mit der Agilität kombiniert, die im Mittelstand gefordert ist. Dieses Wissen ermöglicht den Bau von Lösungen, die technologisch auf Augenhöhe mit Konzern-Standards sind, jedoch ohne unnötigen bürokratischen Overhead auskommen.
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={styles.industrialCol}>
|
||||
<PDFText style={[styles.industrialText, { fontWeight: 'bold', color: COLORS.CHARCOAL, marginBottom: 8 }]}>Fokus Einzelentwicklung</PDFText>
|
||||
<PDFText style={styles.industrialText}>
|
||||
Die Umsetzung erfolgt bewusst als spezialisierter Einzelentwickler. Dies garantiert maximale Geschwindigkeit, direkte Kommunikationswege und volle technologische Verantwortung.
|
||||
</PDFText>
|
||||
<PDFText style={styles.industrialText}>
|
||||
Als direkter technischer Sparringspartner bleibt die Codebasis von der ersten bis zur letzten Zeile transparent und wartbar. Diese Unmittelbarkeit stellt sicher, dass Ergebnisse sowohl technisch sauber als auch wirtschaftlich sinnvoll realisiert werden.
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={{ marginTop: 32, paddingVertical: 16, borderTopWidth: 1, borderTopColor: COLORS.GRID }}>
|
||||
<PDFText style={[styles.industrialText, { fontWeight: 'bold', color: COLORS.CHARCOAL, marginBottom: 4 }]}>Infrastruktur & Souveränität</PDFText>
|
||||
<PDFText style={styles.industrialText}>
|
||||
Es wird keine instabile Prototyp-Software geliefert, sondern produktionsreife Systeme, die technisch skalierbar bleiben. Die Codebasis folgt modernen Standards – bei wachsenden Ansprüchen oder dem Wechsel zu einer Agentur kann der Quellcode jederzeit nahtlos übernommen und weitergeführt werden.
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
|
||||
export const CrossSellModule = ({ state }: any) => {
|
||||
const isWebsite = state.projectType === 'website';
|
||||
const title = isWebsite ? "Weitere Potenziale" : "Websites & Ökosysteme";
|
||||
const subtitle = isWebsite ? "Automatisierung und Prozessoptimierung" : "Technische Infrastruktur ohne Kompromisse";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PDFText style={styles.industrialTitle}>{title}</PDFText>
|
||||
<PDFText style={styles.industrialSubtitle}>{subtitle}</PDFText>
|
||||
<Divider style={{ marginVertical: 16, backgroundColor: COLORS.GRID }} />
|
||||
<PDFView style={[styles.industrialGrid2, { marginTop: 16 }]} >
|
||||
{isWebsite ? (
|
||||
<>
|
||||
<PDFView style={[styles.industrialCol, { marginRight: '8%' }]}>
|
||||
<PDFText style={styles.industrialTextLead}>Über die klassische Webpräsenz hinaus werden maßgeschneiderte Lösungen zur Automatisierung von Routine-Prozessen angeboten. Dies ermöglicht eine signifikante Effizienzsteigerung im Tagesgeschäft.</PDFText>
|
||||
<PDFText style={[styles.industrialText, { fontWeight: 'bold' }]}>Keine Abos. Keine komplexen neuen Systeme. Gezielte Zeitersparnis.</PDFText>
|
||||
<PDFView style={{ marginTop: 24, padding: 16, backgroundColor: '#f8fafc', borderLeftWidth: 2, borderLeftColor: COLORS.GRID }}>
|
||||
<PDFText style={[styles.industrialText, { fontWeight: 'bold', color: COLORS.CHARCOAL, marginBottom: 4 }]}>Individuelle Analyse</PDFText>
|
||||
<PDFText style={styles.industrialText}>Spezifische Prozesse werden auf technisches Automatisierungspotenzial untersucht. Das Ergebnis liefert Klarheit über die wirtschaftliche Sinnhaftigkeit einer Umsetzung.</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
<PDFView style={styles.industrialCol}>
|
||||
<IndustrialCard title="DOKUMENT-AUTOMATION">
|
||||
<PDFText style={styles.industrialText}>Erstellung von PDF-Angeboten, Berichten oder Protokollen in Sekunden statt Stunden.</PDFText>
|
||||
</IndustrialCard>
|
||||
<IndustrialCard title="EXCEL-LOGIK">
|
||||
<PDFText style={styles.industrialText}>Intelligente Tabellen und automatisierte Auswertungen bestehender Datensätze.</PDFText>
|
||||
</IndustrialCard>
|
||||
<IndustrialCard title="KI-ASSISTENZ">
|
||||
<PDFText style={styles.industrialText}>Effiziente Verarbeitung von analogen Dokumenten oder handschriftlichen Notizen mittels KI.</PDFText>
|
||||
</IndustrialCard>
|
||||
</PDFView>
|
||||
</>
|
||||
) : (
|
||||
<PDFView style={{ width: '100%' }}>
|
||||
<PDFText style={styles.industrialTextLead}>Bereitstellung einer stabilen technischen Basis ohne Abhängigkeiten von Baukasten-Systemen oder Agenturen.</PDFText>
|
||||
<PDFText style={styles.industrialText}>Entwicklung performanter Frontends und skalierbarer Backends. Die Auslieferung erfolgt als kontrollierbarer und nachhaltiger Quellcode.</PDFText>
|
||||
</PDFView>
|
||||
)}
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
};
|
||||
29
apps/web/src/components/pdf/modules/BriefingModule.tsx
Normal file
29
apps/web/src/components/pdf/modules/BriefingModule.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer';
|
||||
import { DocumentTitle, COLORS, FONT_SIZES } from '../SharedUI';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 24 },
|
||||
sectionTitle: { fontSize: FONT_SIZES.BODY + 1, fontWeight: 'bold', marginBottom: 8, color: COLORS.CHARCOAL },
|
||||
visionText: { fontSize: FONT_SIZES.BODY, color: COLORS.TEXT_MAIN, lineHeight: 1.8, textAlign: 'justify' },
|
||||
});
|
||||
|
||||
export const BriefingModule = ({ state }: any) => (
|
||||
<>
|
||||
<DocumentTitle title="Projektdetails" />
|
||||
{state.briefingSummary && (
|
||||
<PDFView style={styles.section}>
|
||||
<PDFText style={styles.sectionTitle}>Briefing Analyse</PDFText>
|
||||
<PDFText style={{ fontSize: FONT_SIZES.BODY, color: COLORS.TEXT_MAIN, lineHeight: 1.6, textAlign: 'justify' }}>{state.briefingSummary}</PDFText>
|
||||
</PDFView>
|
||||
)}
|
||||
{state.designVision && (
|
||||
<PDFView style={[styles.section, { padding: 12, borderLeftWidth: 2, borderLeftColor: COLORS.DIVIDER, backgroundColor: COLORS.GRID }]}>
|
||||
<PDFText style={[styles.sectionTitle, { color: COLORS.CHARCOAL, marginBottom: 4 }]}>Strategische Vision</PDFText>
|
||||
<PDFText style={[styles.visionText, { lineHeight: 1.6 }]}>{state.designVision}</PDFText>
|
||||
</PDFView>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
106
apps/web/src/components/pdf/modules/CommonModules.tsx
Normal file
106
apps/web/src/components/pdf/modules/CommonModules.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { View as PDFView, Text as PDFText, StyleSheet, Image as PDFImage } from '@react-pdf/renderer';
|
||||
import { DocumentTitle, Divider, COLORS, FONT_SIZES } from '../SharedUI';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 16 },
|
||||
pricingGrid: { marginTop: 12 },
|
||||
pricingRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: COLORS.DIVIDER, paddingVertical: 12, alignItems: 'flex-start' },
|
||||
pricingTitle: { width: '30%', fontSize: FONT_SIZES.BODY, fontWeight: 'bold', color: COLORS.CHARCOAL, paddingRight: 15 },
|
||||
pricingDesc: { width: '55%', fontSize: FONT_SIZES.SUB, color: COLORS.TEXT_DIM, lineHeight: 1.5, paddingRight: 10 },
|
||||
pricingTag: { width: '15%', fontSize: FONT_SIZES.BODY, fontWeight: 'bold', textAlign: 'right', color: COLORS.CHARCOAL },
|
||||
configLabel: { fontSize: FONT_SIZES.BLUEPRINT, color: COLORS.TEXT_LIGHT, textTransform: 'uppercase', marginBottom: 8 },
|
||||
});
|
||||
|
||||
export const techPageModule = ({ techDetails, headerIcon }: any) => (
|
||||
<>
|
||||
<DocumentTitle title="Technische Umsetzung" />
|
||||
<PDFView style={styles.section}>
|
||||
<PDFView style={styles.pricingGrid}>
|
||||
{techDetails?.map((item: any, i: number) => (
|
||||
<PDFView key={i} style={styles.pricingRow}>
|
||||
<PDFText style={[styles.pricingTitle, { width: '30%' }]}>{item.t}</PDFText>
|
||||
<PDFText style={[styles.pricingDesc, { width: '70%', paddingRight: 0 }]}>{item.d}</PDFText>
|
||||
</PDFView>
|
||||
))}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
|
||||
export const TransparenzModule = ({ pricing }: any) => (
|
||||
<>
|
||||
<DocumentTitle title="Preis-Transparenz & Modell" />
|
||||
<PDFView style={styles.section}>
|
||||
<PDFView style={styles.pricingGrid}>
|
||||
<PDFView style={styles.pricingRow}>
|
||||
<PDFText style={styles.pricingTitle}>Fundament</PDFText>
|
||||
<PDFText style={styles.pricingDesc}>Setup, Infrastruktur, Hosting, SEO-Basics, Staging & Live-Umgebungen.</PDFText>
|
||||
<PDFText style={styles.pricingTag}>{pricing.BASE_WEBSITE?.toLocaleString('de-DE')} €</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.pricingRow}>
|
||||
<PDFText style={styles.pricingTitle}>Seiten</PDFText>
|
||||
<PDFText style={styles.pricingDesc}>Layout & Umsetzung individueller Seiten. Responsive Design / Cross-Browser.</PDFText>
|
||||
<PDFText style={styles.pricingTag}>{pricing.PAGE?.toLocaleString('de-DE')} € / Stk</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.pricingRow}>
|
||||
<PDFText style={styles.pricingTitle}>Features</PDFText>
|
||||
<PDFText style={styles.pricingDesc}>Abgeschlossene Systeme (z. B. Blog, Jobs, Produkte) inkl. Datenstruktur.</PDFText>
|
||||
<PDFText style={styles.pricingTag}>{pricing.FEATURE?.toLocaleString('de-DE')} € / Stk</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.pricingRow}>
|
||||
<PDFText style={styles.pricingTitle}>Funktionen</PDFText>
|
||||
<PDFText style={styles.pricingDesc}>Logik-Einheiten wie Filter, Suchen oder Kontakt-Schnittstellen.</PDFText>
|
||||
<PDFText style={styles.pricingTag}>{pricing.FUNCTION?.toLocaleString('de-DE')} € / Stk</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.pricingRow}>
|
||||
<PDFText style={styles.pricingTitle}>Schnittstellen</PDFText>
|
||||
<PDFText style={styles.pricingDesc}>Anbindung externer Systeme (CRM, ERP, Payment) zur Synchronisation.</PDFText>
|
||||
<PDFText style={styles.pricingTag}>ab {pricing.API_INTEGRATION?.toLocaleString('de-DE')} € / Stk</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.pricingRow}>
|
||||
<PDFText style={styles.pricingTitle}>CMS Setup</PDFText>
|
||||
<PDFText style={styles.pricingDesc}>Konfiguration Headless CMS zur unabhängigen Datenpflege aller Module.</PDFText>
|
||||
<PDFText style={pricing.CMS_SETUP ? styles.pricingTag : [styles.pricingTag, { color: COLORS.TEXT_LIGHT }]}>{pricing.CMS_SETUP?.toLocaleString('de-DE')} €</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.pricingRow}>
|
||||
<PDFText style={styles.pricingTitle}>Inszenierung</PDFText>
|
||||
<PDFText style={styles.pricingDesc}>Interaktions-Mechanismen, Konfiguratoren oder visuelles Storytelling.</PDFText>
|
||||
<PDFText style={styles.pricingTag}>ab {pricing.VISUAL_STAGING?.toLocaleString('de-DE')} €</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.pricingRow}>
|
||||
<PDFText style={styles.pricingTitle}>Sprachen</PDFText>
|
||||
<PDFText style={styles.pricingDesc}>Skalierung der System-Architektur auf zusätzliche Sprachversionen.</PDFText>
|
||||
<PDFText style={styles.pricingTag}>+20% / Sprache</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.pricingRow}>
|
||||
<PDFText style={styles.pricingTitle}>Initial-Pflege</PDFText>
|
||||
<PDFText style={styles.pricingDesc}>Manuelle Aufbereitung & Übernahme von Datensätzen in das Zielsystem.</PDFText>
|
||||
<PDFText style={styles.pricingTag}>{pricing.NEW_DATASET?.toLocaleString('de-DE')} € / Stk</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.pricingRow}>
|
||||
<PDFText style={styles.pricingTitle}>Sorglos-Paket</PDFText>
|
||||
<PDFText style={styles.pricingDesc}>Betrieb, Hosting, Updates & Monitoring gemäß AGB Punkt 7a.</PDFText>
|
||||
<PDFText style={styles.pricingTag}>Inklusive 1 Jahr</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
|
||||
export const PrinciplesModule = ({ principles }: any) => (
|
||||
<>
|
||||
<DocumentTitle title="Prinzipien & Standards" />
|
||||
<PDFView style={[styles.pricingGrid, { marginTop: 8 }]}>
|
||||
{principles?.map((item: any, i: number) => (
|
||||
<PDFView key={i} style={styles.pricingRow}>
|
||||
<PDFText style={[styles.pricingTitle, { width: '30%' }]}>{item.t}</PDFText>
|
||||
<PDFText style={[styles.pricingDesc, { width: '70%', paddingRight: 0 }]}>{item.d}</PDFText>
|
||||
</PDFView>
|
||||
))}
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
|
||||
55
apps/web/src/components/pdf/modules/EstimationModule.tsx
Normal file
55
apps/web/src/components/pdf/modules/EstimationModule.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer';
|
||||
import { DocumentTitle } from '../SharedUI';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
table: { marginTop: 12 },
|
||||
tableHeader: { flexDirection: 'row', paddingBottom: 8, borderBottomWidth: 1, borderBottomColor: '#334155', marginBottom: 12 },
|
||||
tableRow: { flexDirection: 'row', paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#f8fafc', alignItems: 'flex-start' },
|
||||
colPos: { width: '8%' },
|
||||
colDesc: { width: '62%' },
|
||||
colQty: { width: '10%', textAlign: 'center' },
|
||||
colPrice: { width: '20%', textAlign: 'right' },
|
||||
headerText: { fontSize: 7, fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: 1 },
|
||||
posText: { fontSize: 8, color: '#999999' },
|
||||
itemTitle: { fontSize: 10, fontWeight: 'bold', marginBottom: 4 },
|
||||
itemDesc: { fontSize: 8, color: '#666666', lineHeight: 1.4 },
|
||||
priceText: { fontSize: 10, fontWeight: 'bold' },
|
||||
summaryContainer: { borderTopWidth: 1, borderTopColor: '#334155', paddingTop: 8 },
|
||||
summaryRow: { flexDirection: 'row', justifyContent: 'flex-end', paddingVertical: 4, alignItems: 'baseline' },
|
||||
summaryLabel: { fontSize: 7, color: '#64748b', textTransform: 'uppercase', letterSpacing: 1, fontWeight: 'bold', marginRight: 12 },
|
||||
summaryValue: { fontSize: 9, fontWeight: 'bold', width: 100, textAlign: 'right' },
|
||||
totalRow: { flexDirection: 'row', justifyContent: 'flex-end', paddingTop: 12, marginTop: 8, borderTopWidth: 2, borderTopColor: '#334155', alignItems: 'baseline' },
|
||||
});
|
||||
|
||||
export const EstimationModule = ({ state, positions, totalPrice, date }: any) => (
|
||||
<>
|
||||
<DocumentTitle title="Kostenschätzung" subLines={[`Datum: ${date}`, `Projekt: ${state.projectType === 'website' ? 'Website' : 'Web App'}`]} />
|
||||
<PDFView style={styles.table}>
|
||||
<PDFView style={styles.tableHeader}>
|
||||
<PDFText style={[styles.headerText, styles.colPos]}>Pos</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colDesc]}>Beschreibung</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colQty]}>Menge</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colPrice]}>Betrag</PDFText>
|
||||
</PDFView>
|
||||
{positions.map((item: any, i: number) => (
|
||||
<PDFView key={i} style={styles.tableRow} wrap={false}>
|
||||
<PDFText style={[styles.posText, styles.colPos]}>{item.pos.toString().padStart(2, '0')}</PDFText>
|
||||
<PDFView style={styles.colDesc}>
|
||||
<PDFText style={styles.itemTitle}>{item.title}</PDFText>
|
||||
<PDFText style={styles.itemDesc}>{state.positionDescriptions?.[item.title] || item.desc}</PDFText>
|
||||
</PDFView>
|
||||
<PDFText style={[styles.posText, styles.colQty]}>{item.qty}</PDFText>
|
||||
<PDFText style={[styles.priceText, styles.colPrice]}>{item.price > 0 ? `${item.price.toLocaleString('de-DE')} €` : 'n. A.'}</PDFText>
|
||||
</PDFView>
|
||||
))}
|
||||
</PDFView>
|
||||
<PDFView style={styles.summaryContainer} wrap={false}>
|
||||
<PDFView style={styles.summaryRow}><PDFText style={styles.summaryLabel}>Nettobetrag</PDFText><PDFText style={styles.summaryValue}>{totalPrice.toLocaleString('de-DE')} €</PDFText></PDFView>
|
||||
<PDFView style={styles.summaryRow}><PDFText style={styles.summaryLabel}>Umsatzsteuer (19%)</PDFText><PDFText style={styles.summaryValue}>{(totalPrice * 0.19).toLocaleString('de-DE')} €</PDFText></PDFView>
|
||||
<PDFView style={styles.totalRow}><PDFText style={styles.summaryLabel}>Gesamtbetrag (Brutto)</PDFText><PDFText style={[styles.summaryValue, { fontSize: 14 }]}>{(totalPrice * 1.19).toLocaleString('de-DE')} €</PDFText></PDFView>
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
81
apps/web/src/components/pdf/modules/FrontPageModule.tsx
Normal file
81
apps/web/src/components/pdf/modules/FrontPageModule.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { View as PDFView, Text as PDFText, Image as PDFImage, StyleSheet } from '@react-pdf/renderer';
|
||||
import { COLORS, FONT_SIZES } from '../SharedUI';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
titlePage: {
|
||||
flex: 1, // Fill the whole page
|
||||
padding: 60,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: COLORS.WHITE,
|
||||
},
|
||||
titleBrandIcon: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: COLORS.CHARCOAL,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
brandIconText: {
|
||||
fontSize: 40,
|
||||
color: COLORS.WHITE,
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
titleProjectName: {
|
||||
fontSize: FONT_SIZES.H1,
|
||||
fontWeight: 'bold',
|
||||
color: COLORS.CHARCOAL,
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
maxWidth: '85%',
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
titleCustomerName: {
|
||||
fontSize: FONT_SIZES.H3,
|
||||
color: COLORS.TEXT_DIM,
|
||||
marginBottom: 40,
|
||||
textAlign: 'center',
|
||||
maxWidth: '80%',
|
||||
},
|
||||
titleDocumentType: {
|
||||
fontSize: FONT_SIZES.BODY + 1, // ~10
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 4,
|
||||
marginBottom: 12,
|
||||
},
|
||||
titleDivider: {
|
||||
width: 40,
|
||||
height: 2,
|
||||
backgroundColor: COLORS.CHARCOAL,
|
||||
marginBottom: 40,
|
||||
},
|
||||
titleDate: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
marginTop: 40,
|
||||
},
|
||||
});
|
||||
|
||||
export const FrontPageModule = ({ state, headerIcon, date }: any) => {
|
||||
const fullTitle = `Digitale Webpräsenz für\n${state.companyName || "Ihr Projekt"}`;
|
||||
|
||||
// Responsive font size based on length
|
||||
const fontSize = fullTitle.length > 60 ? 14 : fullTitle.length > 40 ? 18 : 22;
|
||||
|
||||
return (
|
||||
<PDFView style={styles.titlePage}>
|
||||
<PDFView style={styles.titleBrandIcon}>
|
||||
{headerIcon ? <PDFImage src={headerIcon} style={{ width: 40, height: 40 }} /> : <PDFText style={styles.brandIconText}>M</PDFText>}
|
||||
</PDFView>
|
||||
<PDFText style={[styles.titleProjectName, { fontSize }]}>{fullTitle}</PDFText>
|
||||
<PDFView style={{ marginBottom: 40 }} />
|
||||
<PDFText style={styles.titleDate}>{date} | Marc Mintel</PDFText>
|
||||
</PDFView>
|
||||
);
|
||||
};
|
||||
77
apps/web/src/components/pdf/modules/SitemapModule.tsx
Normal file
77
apps/web/src/components/pdf/modules/SitemapModule.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer';
|
||||
import { DocumentTitle, Divider, COLORS, FONT_SIZES } from '../SharedUI';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 32 },
|
||||
intro: { fontSize: FONT_SIZES.BODY, color: COLORS.TEXT_DIM, lineHeight: 1.6, marginBottom: 24, textAlign: 'justify' },
|
||||
sitemapTree: { marginTop: 8 },
|
||||
rootNode: {
|
||||
padding: 12,
|
||||
backgroundColor: COLORS.GRID,
|
||||
marginBottom: 20,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: COLORS.CHARCOAL
|
||||
},
|
||||
rootTitle: { fontSize: FONT_SIZES.H3, fontWeight: 'bold', color: COLORS.CHARCOAL, letterSpacing: 0.5 },
|
||||
categorySection: { marginBottom: 20 },
|
||||
categoryHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingBottom: 6,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.BLUEPRINT,
|
||||
marginBottom: 10
|
||||
},
|
||||
categoryIcon: { width: 8, height: 8, backgroundColor: COLORS.GRID, borderInlineWidth: 1, borderColor: COLORS.DIVIDER, marginRight: 10 },
|
||||
categoryTitle: { fontSize: FONT_SIZES.BODY, fontWeight: 'bold', color: COLORS.CHARCOAL, textTransform: 'uppercase', letterSpacing: 1 },
|
||||
pagesGrid: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||
pageCard: {
|
||||
width: '48%',
|
||||
marginRight: '2%',
|
||||
marginBottom: 12,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.GRID,
|
||||
backgroundColor: '#fafafa'
|
||||
},
|
||||
pageTitle: { fontSize: FONT_SIZES.BODY, fontWeight: 'bold', color: COLORS.TEXT_MAIN, marginBottom: 2 },
|
||||
pageDesc: { fontSize: FONT_SIZES.TINY, color: COLORS.TEXT_DIM, lineHeight: 1.4 },
|
||||
});
|
||||
|
||||
export const SitemapModule = ({ state }: any) => (
|
||||
<>
|
||||
<DocumentTitle title="Informationsarchitektur" />
|
||||
<PDFView style={styles.section}>
|
||||
<PDFText style={styles.intro}>
|
||||
Die folgende Struktur definiert die logische Hierarchie und Benutzerführung. Sie dient als Bauplan für die technische Umsetzung und stellt sicher, dass alle relevanten Geschäftsbereiche intuitiv auffindbar sind.
|
||||
</PDFText>
|
||||
|
||||
<PDFView style={styles.sitemapTree}>
|
||||
<PDFView style={styles.rootNode}>
|
||||
<PDFText style={styles.rootTitle}>Seitenstruktur</PDFText>
|
||||
</PDFView>
|
||||
|
||||
{state.sitemap?.map((cat: any, i: number) => (
|
||||
<PDFView key={i} style={styles.categorySection} wrap={false}>
|
||||
<PDFView style={styles.categoryHeader}>
|
||||
<PDFView style={styles.categoryIcon} />
|
||||
<PDFText style={styles.categoryTitle}>{cat.category}</PDFText>
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={styles.pagesGrid}>
|
||||
{cat.pages.map((p: any, j: number) => (
|
||||
<PDFView key={j} style={[styles.pageCard, j % 2 === 1 ? { marginRight: 0 } : {}]}>
|
||||
<PDFText style={styles.pageTitle}>{p.title}</PDFText>
|
||||
{p.desc && <PDFText style={styles.pageDesc}>{p.desc}</PDFText>}
|
||||
</PDFView>
|
||||
))}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
))}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
Reference in New Issue
Block a user