chore: stabilize apps/web (lint, build, typecheck fixes)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 1m27s
Build & Deploy / 🏗️ Build (push) Failing after 1m31s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 1m27s
Build & Deploy / 🏗️ Build (push) Failing after 1m31s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
This commit is contained in:
@@ -1,154 +1,241 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
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';
|
||||
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,
|
||||
}
|
||||
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>
|
||||
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';
|
||||
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',
|
||||
});
|
||||
export const AgbsPDF = ({
|
||||
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 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 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>
|
||||
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="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="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="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="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="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="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="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="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="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="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="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>
|
||||
);
|
||||
}
|
||||
<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 (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -67,7 +67,6 @@ export const CombinedQuotePDF = ({
|
||||
{showAgbs && (
|
||||
<AgbsPDF
|
||||
mode={mode}
|
||||
state={estimationProps.state}
|
||||
headerIcon={estimationProps.headerIcon}
|
||||
footerLogo={estimationProps.footerLogo}
|
||||
/>
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
'use client';
|
||||
"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 * 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,
|
||||
} 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';
|
||||
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 { TypeStep } from "./ContactForm/steps/TypeStep";
|
||||
import { CompanyStep } from "./ContactForm/steps/CompanyStep";
|
||||
import { PresenceStep } from "./ContactForm/steps/PresenceStep";
|
||||
import { BaseStep } from "./ContactForm/steps/BaseStep";
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
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,
|
||||
@@ -39,28 +48,42 @@ import {
|
||||
ConceptCode,
|
||||
ConceptAutomation,
|
||||
ConceptPrice,
|
||||
HeroArchitecture
|
||||
} from './Landing/ConceptIllustrations';
|
||||
HeroArchitecture,
|
||||
} from "./Landing/ConceptIllustrations";
|
||||
|
||||
export interface ContactFormProps {
|
||||
initialStepIndex?: number;
|
||||
initialState?: FormState;
|
||||
onStepChange?: (index: number) => void;
|
||||
onStateChange?: (state: FormState) => void;
|
||||
onStepChange?: (_index: number) => void;
|
||||
onStateChange?: (_state: FormState) => void;
|
||||
}
|
||||
|
||||
export function ContactForm({ initialStepIndex, initialState: propState, onStepChange, onStateChange }: ContactFormProps = {}) {
|
||||
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 */ }
|
||||
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 stepIndex =
|
||||
initialStepIndex !== undefined ? initialStepIndex : internalStepIndex;
|
||||
const state = propState !== undefined ? propState : internalState;
|
||||
|
||||
const setStepIndex = (val: number) => {
|
||||
@@ -69,8 +92,8 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
};
|
||||
|
||||
const setState = (val: any) => {
|
||||
if (typeof val === 'function') {
|
||||
setInternalState(prev => {
|
||||
if (typeof val === "function") {
|
||||
setInternalState((prev) => {
|
||||
const next = val(prev);
|
||||
onStateChange?.(next);
|
||||
return next;
|
||||
@@ -82,13 +105,14 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
};
|
||||
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [qrCodeData, setQrCodeData] = useState<string>('');
|
||||
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 isRemotion =
|
||||
typeof window !== "undefined" && (window as any).isRemotion;
|
||||
const [isClient, setIsClient] = useState(isRemotion);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,9 +123,9 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
setIsSticky(rect.top <= 80);
|
||||
}
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
handleScroll();
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [isRemotion]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -111,10 +135,10 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
// URL Binding
|
||||
useEffect(() => {
|
||||
if (!searchParams) return;
|
||||
const step = searchParams.get('step');
|
||||
const step = searchParams.get("step");
|
||||
if (step) setStepIndex(parseInt(step));
|
||||
|
||||
const config = searchParams.get('config');
|
||||
const config = searchParams.get("config");
|
||||
if (config) {
|
||||
try {
|
||||
const decoded = JSON.parse(decodeURIComponent(escape(atob(config))));
|
||||
@@ -126,9 +150,9 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
}, [searchParams]);
|
||||
|
||||
const currentUrl = useMemo(() => {
|
||||
if (!isClient) return '';
|
||||
if (!isClient) return "";
|
||||
const params = new URLSearchParams();
|
||||
params.set('step', stepIndex.toString());
|
||||
params.set("step", stepIndex.toString());
|
||||
|
||||
const configData = {
|
||||
projectType: state.projectType,
|
||||
@@ -166,11 +190,13 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
platformType: state.platformType,
|
||||
dontKnows: state.dontKnows,
|
||||
visualStaging: state.visualStaging,
|
||||
complexInteractions: state.complexInteractions
|
||||
complexInteractions: state.complexInteractions,
|
||||
};
|
||||
|
||||
const stateString = btoa(unescape(encodeURIComponent(JSON.stringify(configData))));
|
||||
params.set('config', stateString);
|
||||
const stateString = btoa(
|
||||
unescape(encodeURIComponent(JSON.stringify(configData))),
|
||||
);
|
||||
params.set("config", stateString);
|
||||
|
||||
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
|
||||
}, [state, stepIndex, isClient]);
|
||||
@@ -179,7 +205,9 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
if (isRemotion) return;
|
||||
if (currentUrl && router) {
|
||||
router.replace(currentUrl, { scroll: false });
|
||||
QRCode.toDataURL(currentUrl, { margin: 1, width: 200 }).then(setQrCodeData);
|
||||
QRCode.toDataURL(currentUrl, { margin: 1, width: 200 }).then(
|
||||
setQrCodeData,
|
||||
);
|
||||
}
|
||||
}, [currentUrl, router, isRemotion]);
|
||||
|
||||
@@ -187,14 +215,15 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
return calculateTotals(state, PRICING);
|
||||
}, [state]);
|
||||
|
||||
const { totalPrice, monthlyPrice, totalPagesCount } = totals;
|
||||
// Destructuring moved to PriceCalculation if only used there
|
||||
// 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];
|
||||
return list.includes(id) ? list.filter((i) => i !== id) : [...list, id];
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
@@ -208,7 +237,7 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -228,44 +257,136 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
};
|
||||
|
||||
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' },
|
||||
{
|
||||
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' },
|
||||
{ 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');
|
||||
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')!,
|
||||
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]);
|
||||
|
||||
@@ -276,35 +397,72 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
const renderStepContent = () => {
|
||||
const currentStep = activeSteps[stepIndex];
|
||||
switch (currentStep.id) {
|
||||
case 'type':
|
||||
case "type":
|
||||
return <TypeStep state={state} updateState={updateState} />;
|
||||
case 'company':
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
case "language":
|
||||
return <LanguageStep state={state} updateState={updateState} />;
|
||||
case 'timeline':
|
||||
case "timeline":
|
||||
return <TimelineStep state={state} updateState={updateState} />;
|
||||
case 'contact':
|
||||
case "contact":
|
||||
return <ContactStep state={state} updateState={updateState} />;
|
||||
case 'webapp':
|
||||
case "webapp":
|
||||
return <WebAppStep state={state} updateState={updateState} />;
|
||||
default: return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -329,19 +487,30 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
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 randomInRange = (min: number, max: number) =>
|
||||
Math.random() * (max - min) + min;
|
||||
|
||||
const interval: any = !isRemotion ? setInterval(function () {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
const interval: any = !isRemotion
|
||||
? setInterval(function () {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
return clearInterval(interval);
|
||||
}
|
||||
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;
|
||||
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 {
|
||||
@@ -355,21 +524,48 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
|
||||
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
|
||||
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
|
||||
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'}`}
|
||||
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-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
|
||||
@@ -377,7 +573,7 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
scale: isSticky ? 0.7 : 1,
|
||||
width: isSticky ? 80 : 128,
|
||||
height: isSticky ? 80 : 128,
|
||||
borderRadius: isSticky ? '1.75rem' : '2.5rem'
|
||||
borderRadius: isSticky ? "1.75rem" : "2.5rem",
|
||||
}}
|
||||
className="shrink-0 bg-slate-50 flex items-center justify-center relative shadow-inner z-10"
|
||||
>
|
||||
@@ -390,7 +586,11 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
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}
|
||||
@@ -400,7 +600,11 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
</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 }}
|
||||
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">
|
||||
@@ -410,22 +614,28 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
</motion.div>
|
||||
<motion.h3
|
||||
animate={{
|
||||
fontSize: isSticky ? '1.5rem' : '2.25rem',
|
||||
lineHeight: isSticky ? '2rem' : '2.5rem',
|
||||
color: isSticky ? '#0f172a' : '#0f172a'
|
||||
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')}
|
||||
{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'
|
||||
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')}
|
||||
{activeSteps[stepIndex].description.replace(
|
||||
"{company}",
|
||||
state.companyName || "Ihr Unternehmen",
|
||||
)}
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -433,15 +643,17 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{stepIndex > 0 ? (
|
||||
<motion.button
|
||||
whileHover={{ x: -3, backgroundColor: '#f8fafc' }}
|
||||
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'}`}
|
||||
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'} />}
|
||||
) : (
|
||||
<div className={isSticky ? "w-0" : "w-32"} />
|
||||
)}
|
||||
|
||||
{stepIndex < activeSteps.length - 1 ? (
|
||||
<motion.button
|
||||
@@ -450,9 +662,13 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
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'}`}
|
||||
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" />
|
||||
Weiter{" "}
|
||||
<ChevronRight
|
||||
size={isSticky ? 16 : 20}
|
||||
className="transition-transform group-hover:translate-x-1"
|
||||
/>
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
@@ -461,16 +677,22 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
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'}`}
|
||||
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" />
|
||||
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'}`}>
|
||||
<div
|
||||
className={`flex gap-1.5 transition-all duration-500 ${isSticky ? "h-1" : "h-2.5"}`}
|
||||
>
|
||||
{activeSteps.map((step, i) => (
|
||||
<div
|
||||
key={i}
|
||||
@@ -484,19 +706,36 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
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`}
|
||||
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 }}
|
||||
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')}
|
||||
{step.title.replace(
|
||||
"{company}",
|
||||
state.companyName || "Unternehmen",
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -507,19 +746,25 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
|
||||
{!isSticky && (
|
||||
<div className="flex justify-between mt-4 px-1">
|
||||
{chapters.map((chapter, idx) => {
|
||||
const chapterSteps = activeSteps.filter(s => s.chapter === chapter.id);
|
||||
{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;
|
||||
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'
|
||||
}`}
|
||||
className={`text-[10px] font-bold uppercase tracking-[0.2em] transition-colors duration-500 ${
|
||||
isActive ? "text-slate-900" : "text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{chapter.title}
|
||||
</div>
|
||||
@@ -531,8 +776,11 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="contact-form" onSubmit={handleSubmit} className="min-h-[450px] relative pt-12">
|
||||
|
||||
<form
|
||||
id="contact-form"
|
||||
onSubmit={handleSubmit}
|
||||
className="min-h-[450px] relative pt-12"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeSteps[stepIndex].id}
|
||||
@@ -559,12 +807,18 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
<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>
|
||||
<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."}
|
||||
{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>
|
||||
@@ -574,7 +828,7 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
||||
state={state}
|
||||
totals={totals}
|
||||
isClient={isClient}
|
||||
qrCodeData={qrCodeData}
|
||||
_qrCodeData={qrCodeData}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,13 +4,11 @@ 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";
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
import { ConceptAutomation } from "../../Landing/ConceptIllustrations";
|
||||
import { Download, Share2, RefreshCw } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import dynamic from "next/dynamic";
|
||||
// EstimationPDF will be imported dynamically where used or inside the and client-side block
|
||||
import IconWhite from "../../../assets/logo/Icon White Transparent.png";
|
||||
import LogoBlack from "../../../assets/logo/Logo Black Transparent.png";
|
||||
@@ -21,7 +19,7 @@ interface PriceCalculationProps {
|
||||
state: FormState;
|
||||
totals: Totals;
|
||||
isClient: boolean;
|
||||
qrCodeData: string;
|
||||
_qrCodeData: string;
|
||||
onShare?: () => void;
|
||||
}
|
||||
|
||||
@@ -29,7 +27,7 @@ export function PriceCalculation({
|
||||
state,
|
||||
totals,
|
||||
isClient,
|
||||
qrCodeData,
|
||||
_qrCodeData,
|
||||
onShare,
|
||||
}: PriceCalculationProps) {
|
||||
const {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { FormState } from './types';
|
||||
import * as React from "react";
|
||||
import { type FormState as _FormState } from "./types";
|
||||
import {
|
||||
PRICING as LOGIC_PRICING,
|
||||
PAGE_SAMPLES as LOGIC_PAGE_SAMPLES,
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
API_LABELS as LOGIC_API_LABELS,
|
||||
SOCIAL_LABELS as LOGIC_SOCIAL_LABELS,
|
||||
initialState as LOGIC_INITIAL_STATE,
|
||||
DESIGN_OPTIONS
|
||||
} from '../../logic/pricing';
|
||||
DESIGN_OPTIONS,
|
||||
} from "../../logic/pricing";
|
||||
|
||||
export const PRICING = LOGIC_PRICING;
|
||||
export const PAGE_SAMPLES = LOGIC_PAGE_SAMPLES;
|
||||
@@ -40,15 +40,50 @@ 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" />
|
||||
<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" />
|
||||
<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: (
|
||||
@@ -59,14 +94,29 @@ const VIBE_ILLUSTRATIONS: Record<string, React.ReactNode> = {
|
||||
),
|
||||
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" />
|
||||
<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 => ({
|
||||
export const DESIGN_VIBES = DESIGN_OPTIONS.map((opt) => ({
|
||||
...opt,
|
||||
illustration: VIBE_ILLUSTRATIONS[opt.id]
|
||||
illustration: VIBE_ILLUSTRATIONS[opt.id],
|
||||
}));
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
'use client';
|
||||
"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';
|
||||
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[];
|
||||
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 isWebApp = state.projectType === "web-app";
|
||||
|
||||
const toggleDontKnow = (id: string) => {
|
||||
const current = state.dontKnows || [];
|
||||
if (current.includes(id)) {
|
||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
||||
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
@@ -37,35 +37,61 @@ export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="text-2xl font-bold text-slate-900">
|
||||
{isWebApp ? 'Integrationen & Datenquellen' : 'Schnittstellen (API)'}
|
||||
{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>
|
||||
<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
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('api')}
|
||||
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'
|
||||
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.'}
|
||||
{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.' },
|
||||
{
|
||||
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}
|
||||
@@ -73,10 +99,15 @@ export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
|
||||
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) })}
|
||||
<Checkbox
|
||||
label={opt.label}
|
||||
desc={opt.desc}
|
||||
checked={state.apiSystems.includes(opt.id)}
|
||||
onChange={() =>
|
||||
updateState({
|
||||
apiSystems: toggleItem(state.apiSystems, opt.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
@@ -91,7 +122,9 @@ export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
|
||||
<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>
|
||||
<h4 className="text-2xl font-bold text-slate-900">
|
||||
Weitere Systeme oder eigene APIs?
|
||||
</h4>
|
||||
</div>
|
||||
<RepeatableList
|
||||
items={state.otherTech}
|
||||
@@ -106,6 +139,8 @@ export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
|
||||
);
|
||||
|
||||
function updateTech(index: number) {
|
||||
updateState({ otherTech: state.otherTech.filter((_, idx) => idx !== index) });
|
||||
updateState({
|
||||
otherTech: state.otherTech.filter((_, idx) => idx !== index),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
'use client';
|
||||
"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';
|
||||
import * as React from "react";
|
||||
import { FormState } from "../types";
|
||||
import { ASSET_OPTIONS } from "../constants";
|
||||
import { Checkbox } from "../components/Checkbox";
|
||||
import { RepeatableList } from "../components/RepeatableList";
|
||||
import { motion } from "framer-motion";
|
||||
import { Briefcase, ListPlus } from "lucide-react";
|
||||
import { Reveal } from "../../Reveal";
|
||||
|
||||
interface AssetsStepProps {
|
||||
state: FormState;
|
||||
updateState: (updates: Partial<FormState>) => void;
|
||||
toggleItem: (list: string[], id: string) => string[];
|
||||
updateState: (_updates: Partial<FormState>) => void;
|
||||
toggleItem: (_list: string[], _id: string) => string[];
|
||||
}
|
||||
|
||||
export function AssetsStep({ state, updateState, toggleItem }: AssetsStepProps) {
|
||||
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) });
|
||||
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
@@ -34,15 +38,19 @@ export function AssetsStep({ state, updateState, toggleItem }: AssetsStepProps)
|
||||
<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>
|
||||
<h4 className="text-2xl font-bold text-slate-900">
|
||||
Vorhandene Assets
|
||||
</h4>
|
||||
</div>
|
||||
<motion.button
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('assets')}
|
||||
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'
|
||||
state.dontKnows?.includes("assets")
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
@@ -58,11 +66,17 @@ export function AssetsStep({ state, updateState, toggleItem }: AssetsStepProps)
|
||||
>
|
||||
<div className="relative">
|
||||
<Checkbox
|
||||
key={opt.id} label={opt.label} desc={opt.desc}
|
||||
key={opt.id}
|
||||
label={opt.label}
|
||||
desc={opt.desc}
|
||||
checked={state.assets.includes(opt.id)}
|
||||
onChange={() => updateState({ assets: toggleItem(state.assets, opt.id) })}
|
||||
onChange={() =>
|
||||
updateState({ assets: toggleItem(state.assets, opt.id) })
|
||||
}
|
||||
/>
|
||||
{['logo', 'styleguide', 'content_concept'].includes(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>
|
||||
@@ -81,16 +95,23 @@ export function AssetsStep({ state, updateState, toggleItem }: AssetsStepProps)
|
||||
<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>
|
||||
<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) })}
|
||||
onAdd={(v) =>
|
||||
updateState({ otherAssets: [...state.otherAssets, v] })
|
||||
}
|
||||
onRemove={(i) =>
|
||||
updateState({
|
||||
otherAssets: state.otherAssets.filter((_, idx) => idx !== i),
|
||||
})
|
||||
}
|
||||
placeholder="z.B. Lastenheft, Wireframes, Bilddatenbank..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
'use client';
|
||||
"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';
|
||||
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[];
|
||||
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) });
|
||||
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
@@ -26,7 +33,7 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-8"
|
||||
@@ -52,12 +59,18 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
||||
</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>
|
||||
<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>
|
||||
<span className="text-base">
|
||||
Wählen Sie die Bausteine Ihrer neuen Website.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,9 +78,11 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('pages')}
|
||||
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'
|
||||
state.dontKnows?.includes("pages")
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
@@ -75,12 +90,36 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
||||
</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.' },
|
||||
{
|
||||
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}
|
||||
@@ -88,10 +127,15 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
||||
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) })}
|
||||
<Checkbox
|
||||
label={opt.label}
|
||||
desc={opt.desc}
|
||||
checked={state.selectedPages.includes(opt.id)}
|
||||
onChange={() =>
|
||||
updateState({
|
||||
selectedPages: toggleItem(state.selectedPages, opt.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
@@ -104,17 +148,23 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
||||
<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>
|
||||
<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) })}
|
||||
onRemove={(i) =>
|
||||
updateState({
|
||||
otherPages: state.otherPages.filter((_, idx) => idx !== i),
|
||||
})
|
||||
}
|
||||
placeholder="z.B. Karriere, FAQ, Team-Detail..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
@@ -123,18 +173,27 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
||||
<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>
|
||||
<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
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ otherPagesCount: Math.max(0, state.otherPagesCount - 1) })}
|
||||
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} />
|
||||
@@ -154,7 +213,9 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ otherPagesCount: state.otherPagesCount + 1 })}
|
||||
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} />
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
'use client';
|
||||
"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';
|
||||
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;
|
||||
updateState: (_updates: Partial<FormState>) => void;
|
||||
}
|
||||
|
||||
export function CompanyStep({ state, updateState }: CompanyStepProps) {
|
||||
@@ -23,7 +23,9 @@ export function CompanyStep({ state, updateState }: CompanyStepProps) {
|
||||
<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>
|
||||
<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"
|
||||
@@ -40,18 +42,23 @@ export function CompanyStep({ state, updateState }: CompanyStepProps) {
|
||||
<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>
|
||||
<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) => (
|
||||
{EMPLOYEE_OPTIONS.map((option) => (
|
||||
<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'
|
||||
}`}
|
||||
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>
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
'use client';
|
||||
"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';
|
||||
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;
|
||||
updateState: (_updates: Partial<FormState>) => void;
|
||||
}
|
||||
|
||||
export function ContactStep({ state, updateState }: ContactStepProps) {
|
||||
@@ -19,8 +27,12 @@ export function ContactStep({ state, updateState }: ContactStepProps) {
|
||||
<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.
|
||||
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>
|
||||
@@ -81,31 +93,50 @@ export function ContactStep({ state, updateState }: ContactStepProps) {
|
||||
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
|
||||
<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'
|
||||
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(); }}
|
||||
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] });
|
||||
if (files.length > 0)
|
||||
updateState({
|
||||
contactFiles: [...state.contactFiles, ...files],
|
||||
});
|
||||
}}
|
||||
onClick={() => document.getElementById('contact-upload')?.click()}
|
||||
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] });
|
||||
}} />
|
||||
|
||||
<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
|
||||
<motion.div
|
||||
key="files"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
@@ -113,7 +144,7 @@ export function ContactStep({ state, updateState }: ContactStepProps) {
|
||||
className="w-full space-y-4"
|
||||
>
|
||||
{state.contactFiles.map((file, idx) => (
|
||||
<motion.div
|
||||
<motion.div
|
||||
key={`${file.name}-${idx}`}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
@@ -125,28 +156,42 @@ export function ContactStep({ state, updateState }: ContactStepProps) {
|
||||
<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>
|
||||
<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' }}
|
||||
<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) });
|
||||
}}
|
||||
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>
|
||||
<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
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -156,8 +201,12 @@ export function ContactStep({ state, updateState }: ContactStepProps) {
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
'use client';
|
||||
"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';
|
||||
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;
|
||||
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) });
|
||||
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
@@ -30,21 +37,26 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
||||
<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>
|
||||
<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.
|
||||
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
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('cms')}
|
||||
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'
|
||||
state.dontKnows?.includes("cms")
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
@@ -52,12 +64,12 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
||||
<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'}`}
|
||||
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
|
||||
<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"
|
||||
className="absolute top-1.5 left-1.5 w-9 h-9 bg-white rounded-full"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -70,14 +82,24 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
||||
<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>
|
||||
<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.' },
|
||||
{ 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}
|
||||
@@ -86,20 +108,30 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
||||
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'
|
||||
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>
|
||||
<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
|
||||
{state.expectedAdjustments === "high" && !state.cmsSetup && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, y: 20 }}
|
||||
animate={{ opacity: 1, height: 'auto', y: 0 }}
|
||||
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"
|
||||
>
|
||||
@@ -107,9 +139,13 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
||||
<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-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.
|
||||
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>
|
||||
@@ -122,7 +158,8 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
||||
<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.
|
||||
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">
|
||||
@@ -130,7 +167,8 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
||||
<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.
|
||||
Ohne CMS bleibt die technische Komplexität geringer und das
|
||||
Design ist maximal geschützt vor ungewollten Änderungen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,24 +178,30 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
||||
<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>
|
||||
<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.
|
||||
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
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ newDatasets: Math.max(0, state.newDatasets - 1) })}
|
||||
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
|
||||
<motion.span
|
||||
key={state.newDatasets}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -167,11 +211,13 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
||||
{state.newDatasets}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
<motion.button
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ newDatasets: state.newDatasets + 1 })}
|
||||
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} />
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
'use client';
|
||||
"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';
|
||||
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;
|
||||
updateState: (_updates: Partial<FormState>) => void;
|
||||
}
|
||||
|
||||
export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||
const addColor = () => {
|
||||
if (state.colorScheme.length < 5) {
|
||||
updateState({ colorScheme: [...state.colorScheme, '#000000'] });
|
||||
updateState({ colorScheme: [...state.colorScheme, "#000000"] });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||
const toggleDontKnow = (id: string) => {
|
||||
const current = state.dontKnows || [];
|
||||
if (current.includes(id)) {
|
||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
||||
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
@@ -51,11 +51,13 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||
|
||||
const hslToHex = (h: number, s: number, l: number) => {
|
||||
l /= 100;
|
||||
const a = s * Math.min(l, 1 - 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 Math.round(255 * color)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
};
|
||||
return `#${f(0)}${f(8)}${f(4)}`;
|
||||
};
|
||||
@@ -63,7 +65,7 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||
const count = state.colorScheme.length;
|
||||
const palette = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const h = (hue + (i * (360 / count))) % 360;
|
||||
const h = (hue + i * (360 / count)) % 360;
|
||||
const l = i === 0 ? 95 : i === count - 1 ? 20 : lightness;
|
||||
palette.push(hslToHex(h, saturation, l));
|
||||
}
|
||||
@@ -77,16 +79,22 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||
<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>
|
||||
<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
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('design_vibe')}
|
||||
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'
|
||||
state.dontKnows?.includes("design_vibe")
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
@@ -100,14 +108,31 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||
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'
|
||||
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>
|
||||
<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.div
|
||||
layoutId="activeVibe"
|
||||
className="absolute top-4 right-4 w-3 h-3 bg-white rounded-full"
|
||||
/>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
@@ -121,10 +146,13 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||
<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>
|
||||
<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
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
@@ -134,13 +162,15 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||
<RefreshCw size={16} />
|
||||
Zufall
|
||||
</motion.button>
|
||||
<motion.button
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('color_scheme')}
|
||||
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'
|
||||
state.dontKnows?.includes("color_scheme")
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
@@ -157,7 +187,7 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{state.colorScheme.map((color, i) => (
|
||||
<motion.div
|
||||
<motion.div
|
||||
key={`${i}-${color}`}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
@@ -166,17 +196,19 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||
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}
|
||||
<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>
|
||||
<div className="mt-2 text-center font-mono text-[10px] text-slate-400 uppercase">
|
||||
{color}
|
||||
</div>
|
||||
{state.colorScheme.length > 1 && (
|
||||
<motion.button
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
@@ -190,20 +222,29 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{state.colorScheme.length < 5 && (
|
||||
<motion.button
|
||||
<motion.button
|
||||
layout
|
||||
whileHover={{ scale: 1.05, borderColor: '#0f172a', color: '#0f172a' }}
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -212,14 +253,26 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||
<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>
|
||||
<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) })}
|
||||
onAdd={(v) =>
|
||||
updateState({ references: [...(state.references || []), v] })
|
||||
}
|
||||
onRemove={(i) =>
|
||||
updateState({
|
||||
references: (state.references || []).filter(
|
||||
(_, idx) => idx !== i,
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder="https://beispiel.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
'use client';
|
||||
"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';
|
||||
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[];
|
||||
updateState: (_updates: Partial<FormState>) => void;
|
||||
toggleItem: (_list: string[], _id: string) => string[];
|
||||
}
|
||||
|
||||
export function FeaturesStep({ state, updateState, toggleItem }: FeaturesStepProps) {
|
||||
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) });
|
||||
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
@@ -34,22 +38,31 @@ export function FeaturesStep({ state, updateState, toggleItem }: FeaturesStepPro
|
||||
</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>
|
||||
<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>
|
||||
<span className="text-sm">
|
||||
Module sind funktionale Einheiten, die über einfache
|
||||
Textseiten hinausgehen.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<motion.button
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('features')}
|
||||
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'
|
||||
state.dontKnows?.includes("features")
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
@@ -63,10 +76,13 @@ export function FeaturesStep({ state, updateState, toggleItem }: FeaturesStepPro
|
||||
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) })}
|
||||
<Checkbox
|
||||
label={opt.label}
|
||||
desc={opt.desc}
|
||||
checked={state.features.includes(opt.id)}
|
||||
onChange={() =>
|
||||
updateState({ features: toggleItem(state.features, opt.id) })
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
@@ -79,16 +95,25 @@ export function FeaturesStep({ state, updateState, toggleItem }: FeaturesStepPro
|
||||
<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>
|
||||
<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) })}
|
||||
onAdd={(v) =>
|
||||
updateState({ otherFeatures: [...state.otherFeatures, v] })
|
||||
}
|
||||
onRemove={(i) =>
|
||||
updateState({
|
||||
otherFeatures: state.otherFeatures.filter(
|
||||
(_, idx) => idx !== i,
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder="z.B. Glossar, Download-Center, Partner-Bereich..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
'use client';
|
||||
"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';
|
||||
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[];
|
||||
updateState: (_updates: Partial<FormState>) => void;
|
||||
toggleItem: (_list: string[], _id: string) => string[];
|
||||
}
|
||||
|
||||
export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepProps) {
|
||||
const isWebApp = state.projectType === 'web-app';
|
||||
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) });
|
||||
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
@@ -36,16 +40,20 @@ export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepP
|
||||
<Cpu size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">
|
||||
{isWebApp ? 'Funktionale Anforderungen' : 'Erweiterte Funktionen'}
|
||||
{isWebApp
|
||||
? "Funktionale Anforderungen"
|
||||
: "Erweiterte Funktionen"}
|
||||
</h4>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('functions')}
|
||||
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'
|
||||
state.dontKnows?.includes("functions")
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
@@ -54,63 +62,118 @@ export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepP
|
||||
<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="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="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="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="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') })}
|
||||
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') })}
|
||||
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') })}
|
||||
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') })}
|
||||
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') })}
|
||||
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') })}
|
||||
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') })}
|
||||
label="Echtzeit-Chat"
|
||||
desc="Direkte Kommunikation mit Besuchern."
|
||||
checked={state.functions.includes("chat")}
|
||||
onChange={() =>
|
||||
updateState({
|
||||
functions: toggleItem(state.functions, "chat"),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -125,16 +188,29 @@ export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepP
|
||||
<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>
|
||||
<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..."}
|
||||
onAdd={(v) =>
|
||||
updateState({ otherFunctions: [...state.otherFunctions, v] })
|
||||
}
|
||||
onRemove={(i) =>
|
||||
updateState({
|
||||
otherFunctions: state.otherFunctions.filter(
|
||||
(_, idx) => idx !== i,
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder={
|
||||
isWebApp
|
||||
? "z.B. Echtzeit-Chat, KI-Anbindung, Offline-Modus..."
|
||||
: "z.B. Mitgliederbereich, Event-Kalender, geschützte Downloads..."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
'use client';
|
||||
"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';
|
||||
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;
|
||||
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' },
|
||||
{ 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 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) });
|
||||
updateState({ languagesList: current.filter((l) => l !== lang) });
|
||||
} else {
|
||||
updateState({ languagesList: [...current, lang] });
|
||||
}
|
||||
@@ -34,7 +35,7 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
||||
const toggleDontKnow = (id: string) => {
|
||||
const current = state.dontKnows || [];
|
||||
if (current.includes(id)) {
|
||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
||||
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
@@ -52,15 +53,19 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
||||
<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>
|
||||
<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
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('languages')}
|
||||
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'
|
||||
state.dontKnows?.includes("languages")
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
@@ -71,7 +76,7 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
||||
<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
|
||||
@@ -82,8 +87,8 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
||||
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'
|
||||
? "bg-slate-900 border-slate-900 text-white"
|
||||
: "bg-white border-slate-100 text-slate-600 hover:border-slate-300"
|
||||
}`}
|
||||
>
|
||||
{lang.label}
|
||||
@@ -98,12 +103,14 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
||||
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') {
|
||||
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 = '';
|
||||
updateState({
|
||||
languagesList: [...state.languagesList, val],
|
||||
});
|
||||
e.currentTarget.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -113,24 +120,32 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
||||
|
||||
<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"
|
||||
{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"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
<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>
|
||||
@@ -142,7 +157,9 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
||||
<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>
|
||||
<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}
|
||||
@@ -150,13 +167,15 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
||||
{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
|
||||
<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)}%
|
||||
+{(languagesCount - 1) * 20}%
|
||||
</motion.span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
'use client';
|
||||
"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';
|
||||
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[];
|
||||
updateState: (_updates: Partial<FormState>) => void;
|
||||
toggleItem: (_list: string[], _id: string) => string[];
|
||||
}
|
||||
|
||||
export function PresenceStep({ state, updateState, toggleItem }: PresenceStepProps) {
|
||||
export function PresenceStep({
|
||||
state,
|
||||
updateState,
|
||||
toggleItem,
|
||||
}: PresenceStepProps) {
|
||||
const updateUrl = (id: string, url: string) => {
|
||||
updateState({
|
||||
socialMediaUrls: {
|
||||
...state.socialMediaUrls,
|
||||
[id]: url
|
||||
}
|
||||
[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 },
|
||||
{ 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 (
|
||||
@@ -40,8 +53,12 @@ export function PresenceStep({ state, updateState, toggleItem }: PresenceStepPro
|
||||
<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>
|
||||
<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)"
|
||||
@@ -79,9 +96,11 @@ export function PresenceStep({ state, updateState, toggleItem }: PresenceStepPro
|
||||
<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>
|
||||
<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);
|
||||
@@ -92,15 +111,25 @@ export function PresenceStep({ state, updateState, toggleItem }: PresenceStepPro
|
||||
whileHover={{ y: -8, scale: 1.02 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ socialMedia: toggleItem(state.socialMedia, platform.id) })}
|
||||
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'
|
||||
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'}`}>
|
||||
<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>
|
||||
<span className="font-bold text-base tracking-tight">
|
||||
{platform.label}
|
||||
</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
@@ -109,7 +138,7 @@ export function PresenceStep({ state, updateState, toggleItem }: PresenceStepPro
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{state.socialMedia.map((id) => {
|
||||
const platform = SOCIAL_PLATFORMS.find(p => p.id === id);
|
||||
const platform = SOCIAL_PLATFORMS.find((p) => p.id === id);
|
||||
if (!platform) return null;
|
||||
return (
|
||||
<motion.div
|
||||
@@ -121,14 +150,16 @@ export function PresenceStep({ state, updateState, toggleItem }: PresenceStepPro
|
||||
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>
|
||||
<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] || ''}
|
||||
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"
|
||||
/>
|
||||
@@ -136,10 +167,12 @@ export function PresenceStep({ state, updateState, toggleItem }: PresenceStepPro
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
<p className="text-slate-400 font-medium">
|
||||
Wählen Sie oben Ihre Kanäle aus, um die Links hinzuzufügen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import { FormState } from "../types";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
interface TimelineStepProps {
|
||||
state: FormState;
|
||||
updateState: (updates: Partial<FormState>) => void;
|
||||
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 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) });
|
||||
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
@@ -29,9 +33,11 @@ export function TimelineStep({ state, updateState }: TimelineStepProps) {
|
||||
<h4 className="text-2xl font-bold text-slate-900">Zeitplan</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('timeline')}
|
||||
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'
|
||||
state.dontKnows?.includes("timeline")
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-slate-100 text-slate-500 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
@@ -39,30 +45,58 @@ export function TimelineStep({ state, updateState }: TimelineStepProps) {
|
||||
</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 => (
|
||||
{
|
||||
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'
|
||||
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>
|
||||
<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' && (
|
||||
{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.
|
||||
<strong>Hinweis:</strong> Bei sehr kurzfristigen Deadlines kann ein
|
||||
Express-Zuschlag anfallen, um die Kapazitäten entsprechend zu
|
||||
priorisieren.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -73,9 +107,15 @@ export function TimelineStep({ state, updateState }: TimelineStepProps) {
|
||||
<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-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.
|
||||
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>
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
'use client';
|
||||
"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';
|
||||
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;
|
||||
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" /> },
|
||||
{
|
||||
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
|
||||
@@ -25,15 +38,34 @@ export function TypeStep({ state, updateState }: TypeStepProps) {
|
||||
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'
|
||||
}`}
|
||||
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
|
||||
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>
|
||||
<p className={`text-2xl leading-relaxed ${state.projectType === type.id ? 'text-slate-200' : 'text-slate-500'}`}>{type.desc}</p>
|
||||
<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
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { Users, Shield, Monitor, Smartphone, Globe, Lock } from 'lucide-react';
|
||||
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;
|
||||
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)
|
||||
const next = current.includes(role)
|
||||
? current.filter((r) => r !== role)
|
||||
: [...current, role];
|
||||
updateState({ userRoles: next });
|
||||
};
|
||||
@@ -26,23 +26,39 @@ export function WebAppStep({ state, updateState }: WebAppStepProps) {
|
||||
<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>
|
||||
<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 => (
|
||||
{
|
||||
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'
|
||||
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>
|
||||
<p
|
||||
className={`text-base mt-2 ${state.targetAudience === opt.id ? "text-slate-200" : "text-slate-500"}`}
|
||||
>
|
||||
{opt.desc}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -53,13 +69,21 @@ export function WebAppStep({ state, updateState }: WebAppStepProps) {
|
||||
<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 => (
|
||||
{[
|
||||
"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'
|
||||
(state.userRoles || []).includes(role)
|
||||
? "border-slate-900 bg-slate-900 text-white"
|
||||
: "border-slate-100 bg-white hover:border-slate-200"
|
||||
}`}
|
||||
>
|
||||
{role}
|
||||
@@ -75,19 +99,37 @@ export function WebAppStep({ state, updateState }: WebAppStepProps) {
|
||||
</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 => (
|
||||
{
|
||||
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'
|
||||
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'}>
|
||||
<div
|
||||
className={
|
||||
state.platformType === opt.id ? "text-white" : "text-black"
|
||||
}
|
||||
>
|
||||
{opt.icon}
|
||||
</div>
|
||||
<span className="font-bold text-lg">{opt.label}</span>
|
||||
@@ -103,19 +145,33 @@ export function WebAppStep({ state, updateState }: WebAppStepProps) {
|
||||
</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 => (
|
||||
{
|
||||
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'
|
||||
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>
|
||||
<p
|
||||
className={`text-base mt-2 ${state.dataSensitivity === opt.id ? "text-slate-200" : "text-slate-500"}`}
|
||||
>
|
||||
{opt.desc}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -126,9 +182,17 @@ export function WebAppStep({ state, updateState }: WebAppStepProps) {
|
||||
<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>
|
||||
<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 => (
|
||||
{[
|
||||
"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"
|
||||
@@ -137,7 +201,9 @@ export function WebAppStep({ state, updateState }: WebAppStepProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 italic">Details zur Authentifizierung besprechen wir im Erstgespräch.</p>
|
||||
<p className="text-xs text-slate-400 italic">
|
||||
Details zur Authentifizierung besprechen wir im Erstgespräch.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,339 +1,390 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '../utils/cn';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { MonoLabel } from './Typography';
|
||||
/* eslint-disable no-unused-vars */
|
||||
import * as 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;
|
||||
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>
|
||||
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>
|
||||
{/* 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
|
||||
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 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 });
|
||||
const [scrollState, setScrollState] = React.useState({
|
||||
atTop: true,
|
||||
atBottom: false,
|
||||
isScrollable: false,
|
||||
});
|
||||
|
||||
// Scaling Logic
|
||||
React.useEffect(() => {
|
||||
if (!containerRef.current || noScale) {
|
||||
setScale(1);
|
||||
return;
|
||||
// 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);
|
||||
}
|
||||
|
||||
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);
|
||||
updateScale();
|
||||
const observer = new ResizeObserver(updateScale);
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [desktopWidth, zoom, noScale]);
|
||||
|
||||
const chassisStyle = {
|
||||
height: height === '100%'
|
||||
? '100%'
|
||||
: (finalScaledHeight ? `${finalScaledHeight + headerHeightPx}px` : `calc(${height} + ${headerHeightPx}px)`)
|
||||
};
|
||||
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) {}
|
||||
}, []);
|
||||
|
||||
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" />
|
||||
// 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;
|
||||
|
||||
{!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>
|
||||
)}
|
||||
canvas.width = 100;
|
||||
canvas.height = 100;
|
||||
|
||||
{/* Main Device Chassis */}
|
||||
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
|
||||
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: `
|
||||
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>
|
||||
)}
|
||||
filter: "saturate(2.2) brightness(1.1)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Browser Frame */}
|
||||
{browserFrame && <BrowserChrome url="varnish-cache://secure.klz-cables.com" minimal={minimal} />}
|
||||
{/* 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 = `
|
||||
{/* 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);
|
||||
iframe.contentDocument.head.appendChild(style);
|
||||
setTimeout(updateAmbilight, 600);
|
||||
|
||||
const onScroll = () => {
|
||||
requestAnimationFrame(updateAmbilight);
|
||||
updateScrollState();
|
||||
};
|
||||
const onScroll = () => {
|
||||
requestAnimationFrame(updateAmbilight);
|
||||
updateScrollState();
|
||||
};
|
||||
|
||||
iframe.contentWindow?.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
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>
|
||||
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 (_e) {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
transform: `translateY(-${offsetY}px)`,
|
||||
height: `calc(100% + ${offsetY}px)`,
|
||||
pointerEvents: allowScroll ? "auto" : "none",
|
||||
width: "calc(100% + 20px)", // Bleed for seamless edge
|
||||
marginLeft: "-10px",
|
||||
}}
|
||||
title={title || "Project Display"}
|
||||
/>
|
||||
</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" />}
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
/* eslint-disable no-unused-vars */
|
||||
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 }) => {
|
||||
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">
|
||||
<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"
|
||||
@@ -31,42 +40,70 @@ export const HeroLines: React.FC<LineProps> = ({ className = "", delay = 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"
|
||||
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"
|
||||
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 }} />
|
||||
<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 }) => {
|
||||
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">
|
||||
<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" />
|
||||
<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"
|
||||
@@ -80,22 +117,54 @@ export const GridLines: React.FC<LineProps> = ({ className = "", delay = 0 }) =>
|
||||
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 }} />
|
||||
{/* 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 }) => {
|
||||
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">
|
||||
<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"
|
||||
@@ -116,45 +185,122 @@ export const FlowLines: React.FC<LineProps> = ({ className = "", delay = 0 }) =>
|
||||
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"
|
||||
<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 }} />
|
||||
|
||||
<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 }) => {
|
||||
export const CirclePattern: 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">
|
||||
<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"
|
||||
@@ -167,55 +313,59 @@ export const ServicesFlow: React.FC<LineProps> = ({ className = "", delay = 0 })
|
||||
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"
|
||||
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"
|
||||
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 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;
|
||||
export const ConnectorStart: React.FC<LineProps> = (_props) => null;
|
||||
export const ConnectorBranch: React.FC<LineProps> = (_props) => null;
|
||||
export const ConnectorSplit: React.FC<LineProps> = (_props) => null;
|
||||
export const ConnectorEnd: React.FC<LineProps> = (_props) => null;
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
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">
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { IllustrationProps } from "./types";
|
||||
|
||||
export const ConceptAutomation: React.FC<IllustrationProps> = ({
|
||||
className = "",
|
||||
delay: _delay = 0,
|
||||
}) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 120 120"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<motion.g
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
|
||||
@@ -22,7 +32,9 @@ export const ConceptAutomation: React.FC<IllustrationProps> = ({ className = "",
|
||||
</motion.g>
|
||||
<motion.path
|
||||
d="M 10 60 H 110"
|
||||
stroke="currentColor" strokeWidth="1" className="text-slate-300"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
className="text-slate-300"
|
||||
strokeDasharray="4 4"
|
||||
animate={{ strokeDashoffset: [0, -20] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
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">
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { IllustrationProps } from "./types";
|
||||
|
||||
export const ConceptMessy: React.FC<IllustrationProps> = ({
|
||||
className = "",
|
||||
delay: _delay = 0,
|
||||
}) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 120 120"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<motion.path
|
||||
d="M 20 60 C 30 20, 40 100, 50 60 C 60 20, 70 100, 80 60 C 90 20, 100 100, 110 60"
|
||||
stroke="currentColor" strokeWidth="1" className="text-slate-500"
|
||||
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 }}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
className="text-slate-200 opacity-50"
|
||||
animate={{ opacity: [0.2, 0.5, 0.2] }}
|
||||
transition={{ duration: 3, repeat: Infinity }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,27 +1,50 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
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 }} />
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { IllustrationProps } from "./types";
|
||||
|
||||
export const ConceptSystem: React.FC<IllustrationProps> = ({
|
||||
className = "",
|
||||
delay: _delay = 0,
|
||||
}) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 120 120"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<motion.circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="15"
|
||||
className="fill-slate-900"
|
||||
animate={{ scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 4, repeat: Infinity }}
|
||||
/>
|
||||
{[0, 72, 144, 216, 288].map((angle, i) => {
|
||||
const x = 60 + Math.cos((angle * Math.PI) / 180) * 40;
|
||||
const y = 60 + Math.sin((angle * Math.PI) / 180) * 40;
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
<motion.line
|
||||
x1="60" y1="60" x2={x} y2={y}
|
||||
stroke="currentColor" strokeWidth="1" className="text-slate-400"
|
||||
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"
|
||||
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 }}
|
||||
|
||||
@@ -1,23 +1,43 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
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">
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { IllustrationProps } from "./types";
|
||||
|
||||
export const ConceptTarget: React.FC<IllustrationProps> = ({
|
||||
className = "",
|
||||
delay: _delay = 0,
|
||||
}) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 120 120"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<motion.circle
|
||||
cx="60" cy="60" r="50"
|
||||
stroke="currentColor" strokeWidth="1" className="text-slate-300"
|
||||
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"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="30"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
className="text-slate-400"
|
||||
/>
|
||||
<motion.circle
|
||||
cx="60" cy="60" r="10"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="10"
|
||||
className="fill-slate-900"
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
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" />
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { IllustrationProps } from "./types";
|
||||
|
||||
export const ConceptWebsite: React.FC<IllustrationProps> = ({
|
||||
className = "",
|
||||
delay: _delay = 0,
|
||||
}) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 120 120"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="10"
|
||||
y="20"
|
||||
width="100"
|
||||
height="80"
|
||||
rx="4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
className="text-slate-400"
|
||||
/>
|
||||
<motion.rect
|
||||
x="20" y="35" width="80" height="15" rx="2"
|
||||
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 }}
|
||||
@@ -17,8 +40,22 @@ export const ConceptWebsite: React.FC<IllustrationProps> = ({ className = "", de
|
||||
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" />
|
||||
<rect
|
||||
x="20"
|
||||
y="55"
|
||||
width="35"
|
||||
height="35"
|
||||
rx="2"
|
||||
className="fill-slate-300"
|
||||
/>
|
||||
<rect
|
||||
x="65"
|
||||
y="55"
|
||||
width="35"
|
||||
height="35"
|
||||
rx="2"
|
||||
className="fill-slate-300"
|
||||
/>
|
||||
</motion.g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,56 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
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 }} />
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { IllustrationProps } from "./types";
|
||||
|
||||
export const HeroArchitecture: React.FC<IllustrationProps> = ({
|
||||
className = "",
|
||||
delay: _delay = 0,
|
||||
}) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 400 300"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<motion.rect
|
||||
x="170"
|
||||
y="120"
|
||||
width="60"
|
||||
height="60"
|
||||
rx="8"
|
||||
className="stroke-slate-900 fill-white"
|
||||
strokeWidth="2"
|
||||
animate={{ scale: [1, 1.05, 1] }}
|
||||
transition={{ duration: 4, repeat: Infinity }}
|
||||
/>
|
||||
{[
|
||||
{ x: 80, y: 60 }, { x: 320, y: 60 },
|
||||
{ x: 80, y: 240 }, { x: 320, y: 240 }
|
||||
{ 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"
|
||||
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"
|
||||
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 }}
|
||||
animate={{ scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 3, repeat: Infinity, delay: i * 0.5 }}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => {
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { IllustrationProps } from "./types";
|
||||
|
||||
export const HeroMainIllustration: React.FC<IllustrationProps> = ({
|
||||
className = "",
|
||||
delay: _delay = 0,
|
||||
}) => {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 800 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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);
|
||||
const startDelay = col % 5;
|
||||
return (
|
||||
<motion.g
|
||||
key={`rain-col-${col}`}
|
||||
@@ -33,7 +43,7 @@ export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className =
|
||||
className="fill-slate-900 font-mono"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{(col + row) % 2 === 0 ? '1' : '0'}
|
||||
{(col + row) % 2 === 0 ? "1" : "0"}
|
||||
</text>
|
||||
))}
|
||||
</motion.g>
|
||||
@@ -46,35 +56,141 @@ export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className =
|
||||
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" />
|
||||
<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>
|
||||
<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 }}
|
||||
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" />
|
||||
<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>
|
||||
<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>
|
||||
<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 */}
|
||||
@@ -92,56 +208,227 @@ export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className =
|
||||
{/* Layer 3: Browser/Website */}
|
||||
<motion.g
|
||||
animate={{ y: [0, 4, 0] }}
|
||||
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut", delay: 0.6 }}
|
||||
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" />
|
||||
|
||||
<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" />
|
||||
|
||||
<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" />
|
||||
|
||||
<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" />
|
||||
|
||||
<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" />
|
||||
|
||||
<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" />
|
||||
|
||||
<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" />
|
||||
<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>
|
||||
@@ -176,7 +463,6 @@ export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className =
|
||||
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
</motion.g>
|
||||
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable no-unused-vars */
|
||||
import * as React from "react";
|
||||
|
||||
export interface IllustrationProps {
|
||||
className?: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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';
|
||||
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;
|
||||
@@ -21,30 +21,34 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
description,
|
||||
backLink,
|
||||
backgroundSymbol,
|
||||
className = ""
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<section className={cn("narrow-container relative pt-24 pb-16 md:pt-40 md:pb-24", className)}>
|
||||
<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}
|
||||
<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>
|
||||
<H1 className="max-w-4xl">{title}</H1>
|
||||
</Reveal>
|
||||
|
||||
{description && (
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import { DocumentTitle, Divider, COLORS, FONT_SIZES } from "../SharedUI";
|
||||
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 32 },
|
||||
|
||||
Reference in New Issue
Block a user