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

This commit is contained in:
2026-02-11 11:56:13 +01:00
parent 8ba81809b0
commit ecea90dc91
50 changed files with 5596 additions and 3456 deletions

View File

@@ -1,23 +1,28 @@
import * as React from 'react';
import Image from 'next/image';
import { PageHeader } from '../../src/components/PageHeader';
import { Section } from '../../src/components/Section';
import { Reveal } from '../../src/components/Reveal';
import {
ExperienceIllustration,
ResponsibilityIllustration,
import Image from "next/image";
import { PageHeader } from "../../src/components/PageHeader";
import { Section } from "../../src/components/Section";
import { Reveal } from "../../src/components/Reveal";
import {
ExperienceIllustration,
ResponsibilityIllustration,
ResultIllustration,
ConceptSystem,
ConceptTarget,
ContactIllustration,
HeroLines,
ParticleNetwork,
GridLines
} from '../../src/components/Landing';
import { Check } from 'lucide-react';
import { H3, H4, LeadText, BodyText, Label, MonoLabel } from '../../src/components/Typography';
import { BackgroundGrid, Card, Container } from '../../src/components/Layout';
import { Button } from '../../src/components/Button';
GridLines,
} from "../../src/components/Landing";
import { Check } from "lucide-react";
import {
H3,
H4,
LeadText,
BodyText,
Label,
MonoLabel,
} from "../../src/components/Typography";
import { BackgroundGrid, Card, Container } from "../../src/components/Layout";
import { Button } from "../../src/components/Button";
export default function AboutPage() {
return (
@@ -29,12 +34,12 @@ export default function AboutPage() {
{/* Hero Section */}
<section className="relative pt-32 pb-24 overflow-hidden border-b border-slate-50">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-full opacity-10 pointer-events-none">
<HeroLines className="w-full h-full" />
<HeroLines className="w-full h-full" />
</div>
<div className="absolute right-0 top-0 w-96 h-96 opacity-5 pointer-events-none">
<GridLines />
<GridLines />
</div>
<Container variant="narrow" className="relative z-10">
<div className="flex flex-col items-center text-center space-y-12">
<Reveal>
@@ -42,12 +47,14 @@ export default function AboutPage() {
{/* Structural rings around avatar */}
<div className="absolute inset-0 -m-8 border border-slate-100 rounded-full animate-[spin_30s_linear_infinite] opacity-50" />
<div className="absolute inset-0 -m-4 border border-slate-200 rounded-full animate-[spin_20s_linear_infinite_reverse] opacity-30" />
<div className="relative w-32 h-32 md:w-40 md:h-40 rounded-full overflow-hidden border border-slate-200 shadow-xl bg-white p-1 group">
<div className="w-full h-full rounded-full overflow-hidden">
<img
<Image
src="/header.webp"
alt="Marc Mintel"
width={160}
height={160}
className="w-full h-full object-cover grayscale transition-all duration-1000 ease-in-out scale-110 group-hover:scale-100 group-hover:grayscale-0"
/>
</div>
@@ -58,13 +65,19 @@ export default function AboutPage() {
<div className="space-y-6 max-w-3xl">
<Reveal delay={0.1}>
<div className="flex items-center justify-center gap-4 mb-4">
<div className="h-px w-8 bg-slate-900"></div>
<MonoLabel className="text-slate-900">Digital Architect</MonoLabel>
<div className="h-px w-8 bg-slate-900"></div>
<div className="h-px w-8 bg-slate-900"></div>
<MonoLabel className="text-slate-900">
Digital Architect
</MonoLabel>
<div className="h-px w-8 bg-slate-900"></div>
</div>
</Reveal>
<PageHeader
title={<>Über <span className="text-slate-200">mich.</span></>}
<PageHeader
title={
<>
Über <span className="text-slate-200">mich.</span>
</>
}
description="Warum ich tue, was ich tue und wie Sie davon profitieren."
className="pt-0 md:pt-0"
/>
@@ -87,7 +100,9 @@ export default function AboutPage() {
<Reveal>
<H3 className="text-3xl md:text-5xl leading-tight max-w-3xl">
15 Jahre Web-Entwicklung. <br />
<span className="text-slate-200">Vom Designer zum Architekten.</span>
<span className="text-slate-200">
Vom Designer zum Architekten.
</span>
</H3>
</Reveal>
@@ -95,13 +110,17 @@ export default function AboutPage() {
<Reveal delay={0.1}>
<div className="space-y-8">
<LeadText className="text-xl md:text-2xl text-slate-400">
Ich habe Agenturen, Konzerne und Startups von innen gesehen. Dabei habe ich gelernt, what really counts: <span className="text-slate-900">Ergebnisse, nicht Prozesse.</span>
Ich habe Agenturen, Konzerne und Startups von innen gesehen.
Dabei habe ich gelernt, what really counts:{" "}
<span className="text-slate-900">
Ergebnisse, nicht Prozesse.
</span>
</LeadText>
<ul className="space-y-4">
{[
'Komplexe Systeme vereinfacht',
'Performance-Probleme gelöst',
'Nachhaltige Software-Architekturen gebaut'
"Komplexe Systeme vereinfacht",
"Performance-Probleme gelöst",
"Nachhaltige Software-Architekturen gebaut",
].map((item, i) => (
<li key={i} className="flex items-center gap-4 group">
<div className="w-1.5 h-1.5 bg-slate-900 rounded-full group-hover:scale-150 transition-transform" />
@@ -112,11 +131,22 @@ export default function AboutPage() {
</div>
</Reveal>
<Reveal delay={0.2}>
<Card variant="gray" hover={false} padding="normal" className="group">
<H4 className="text-2xl mb-6">Mein Fokus heute: Direkte Zusammenarbeit ohne Reibungsverluste.</H4>
<Card
variant="gray"
hover={false}
padding="normal"
className="group"
>
<H4 className="text-2xl mb-6">
Mein Fokus heute: Direkte Zusammenarbeit ohne
Reibungsverluste.
</H4>
<div className="flex flex-wrap gap-3">
{['Effizient', 'Pragmatisch', 'Verlässlich'].map((tag, i) => (
<span key={i} className="px-4 py-2 bg-white border border-slate-200 rounded-full shadow-sm">
{["Effizient", "Pragmatisch", "Verlässlich"].map((tag, i) => (
<span
key={i}
className="px-4 py-2 bg-white border border-slate-200 rounded-full shadow-sm"
>
<Label className="text-slate-900">{tag}</Label>
</span>
))}
@@ -147,14 +177,24 @@ export default function AboutPage() {
<div className="md:col-span-8 space-y-8">
<Reveal delay={0.1}>
<LeadText className="text-xl md:text-2xl text-slate-400">
In der klassischen Agenturwelt verschwindet Verantwortung oft hinter Hierarchien. Bei mir gibt es nur <span className="text-slate-900">einen Ansprechpartner:</span> Mich.
In der klassischen Agenturwelt verschwindet Verantwortung oft
hinter Hierarchien. Bei mir gibt es nur{" "}
<span className="text-slate-900">einen Ansprechpartner:</span>{" "}
Mich.
</LeadText>
</Reveal>
<Reveal delay={0.2}>
<Card variant="white" padding="normal" className="flex flex-row items-start gap-6 group">
<div className="w-12 h-12 bg-slate-900 text-white rounded-xl flex items-center justify-center shrink-0 font-bold text-xl group-hover:rotate-12 transition-transform duration-500">!</div>
<Card
variant="white"
padding="normal"
className="flex flex-row items-start gap-6 group"
>
<div className="w-12 h-12 bg-slate-900 text-white rounded-xl flex items-center justify-center shrink-0 font-bold text-xl group-hover:rotate-12 transition-transform duration-500">
!
</div>
<BodyText className="text-slate-900 font-medium text-lg md:text-xl leading-relaxed">
Ich übernehme die volle Verantwortung für die technische Umsetzung und Qualität Ihres Projekts. Ohne Ausreden.
Ich übernehme die volle Verantwortung für die technische
Umsetzung und Qualität Ihres Projekts. Ohne Ausreden.
</BodyText>
</Card>
</Reveal>
@@ -182,11 +222,20 @@ export default function AboutPage() {
<div className="space-y-8">
<Reveal delay={0.1}>
<LeadText className="text-xl text-slate-400">
Ich baue keine Wegwerf-Produkte. Meine Systeme sind so konzipiert, dass sie mit Ihrem Unternehmen <span className="text-slate-900">wachsen können.</span>
Ich baue keine Wegwerf-Produkte. Meine Systeme sind so
konzipiert, dass sie mit Ihrem Unternehmen{" "}
<span className="text-slate-900">wachsen können.</span>
</LeadText>
</Reveal>
<div className="grid grid-cols-2 gap-4">
{['Skalierbar', 'Wartbar', 'Performant', 'Sicher', 'Unabhängig', 'Zukunftssicher'].map((item, i) => (
{[
"Skalierbar",
"Wartbar",
"Performant",
"Sicher",
"Unabhängig",
"Zukunftssicher",
].map((item, i) => (
<Reveal key={i} delay={0.2 + i * 0.05}>
<div className="flex items-center gap-3 group">
<div className="w-5 h-5 rounded-full bg-slate-50 flex items-center justify-center group-hover:bg-slate-900 transition-colors duration-500">
@@ -199,11 +248,18 @@ export default function AboutPage() {
</div>
</div>
<Reveal delay={0.3}>
<Card variant="dark" padding="normal" className="relative rounded-2xl overflow-hidden group">
<Card
variant="dark"
padding="normal"
className="relative rounded-2xl overflow-hidden group"
>
<div className="absolute top-0 right-0 w-48 h-48 bg-white/5 -translate-y-24 translate-x-24 rounded-full blur-3xl group-hover:bg-white/10 transition-colors duration-1000" />
<H4 className="text-white text-2xl mb-6 relative z-10">Kein Vendor Lock-in.</H4>
<H4 className="text-white text-2xl mb-6 relative z-10">
Kein Vendor Lock-in.
</H4>
<LeadText className="text-slate-400 text-lg relative z-10 leading-relaxed">
Sie behalten die volle Kontrolle über Ihren Code und Ihre Daten. Keine Abhängigkeit von proprietären Systemen.
Sie behalten die volle Kontrolle über Ihren Code und Ihre
Daten. Keine Abhängigkeit von proprietären Systemen.
</LeadText>
</Card>
</Reveal>
@@ -231,9 +287,19 @@ export default function AboutPage() {
<div className="space-y-6">
<Label>Kein:</Label>
<div className="flex flex-wrap gap-3">
{['Agentur-Zirkus', 'Meeting-Marathon', 'Ticket-Wahnsinn', 'CMS-Frust'].map((item, i) => (
<span key={i} className="px-4 py-2 border border-slate-100 rounded-full bg-slate-50/50">
<BodyText className="text-slate-400 line-through text-base mb-0">{item}</BodyText>
{[
"Agentur-Zirkus",
"Meeting-Marathon",
"Ticket-Wahnsinn",
"CMS-Frust",
].map((item, i) => (
<span
key={i}
className="px-4 py-2 border border-slate-100 rounded-full bg-slate-50/50"
>
<BodyText className="text-slate-400 line-through text-base mb-0">
{item}
</BodyText>
</span>
))}
</div>
@@ -244,9 +310,18 @@ export default function AboutPage() {
<Label className="text-slate-900">Sondern:</Label>
<div className="space-y-8">
{[
{ label: 'Direkte Kommunikation', desc: 'Kurze Wege, schnelle Entscheidungen.' },
{ label: 'Echte Expertise', desc: 'Fundiertes Wissen aus 15 Jahren Praxis.' },
{ label: 'Messbare Qualität', desc: 'Code, der hält, was er verspricht.' }
{
label: "Direkte Kommunikation",
desc: "Kurze Wege, schnelle Entscheidungen.",
},
{
label: "Echte Expertise",
desc: "Fundiertes Wissen aus 15 Jahren Praxis.",
},
{
label: "Messbare Qualität",
desc: "Code, der hält, was er verspricht.",
},
].map((item, i) => (
<Reveal key={i} delay={0.2 + i * 0.1}>
<div className="flex gap-6 items-start group">
@@ -255,7 +330,9 @@ export default function AboutPage() {
</div>
<div className="space-y-1">
<H4 className="text-xl">{item.label}</H4>
<BodyText className="text-base text-slate-400">{item.desc}</BodyText>
<BodyText className="text-base text-slate-400">
{item.desc}
</BodyText>
</div>
</div>
</Reveal>
@@ -283,18 +360,22 @@ export default function AboutPage() {
</H3>
</Reveal>
<Card variant="white" hover={false} padding="large" className="rounded-3xl shadow-xl relative overflow-hidden group">
<Card
variant="white"
hover={false}
padding="large"
className="rounded-3xl shadow-xl relative overflow-hidden group"
>
<div className="absolute top-0 right-0 w-96 h-96 bg-slate-50 -translate-y-1/2 translate-x-1/2 rounded-full blur-[80px] group-hover:bg-slate-100 transition-colors duration-1000" />
<div className="relative z-10 space-y-8">
<LeadText className="text-2xl md:text-4xl leading-tight max-w-2xl text-slate-400">
Lassen Sie uns gemeinsam etwas bauen, das <span className="text-slate-900">wirklich funktioniert.</span>
Lassen Sie uns gemeinsam etwas bauen, das{" "}
<span className="text-slate-900">wirklich funktioniert.</span>
</LeadText>
<div className="pt-4">
<Button href="/contact">
Projekt anfragen
</Button>
<Button href="/contact">Projekt anfragen</Button>
</div>
</div>
</Card>

View File

@@ -1,17 +1,19 @@
import * as React from 'react';
import { notFound } from 'next/navigation';
import { blogPosts } from '../../../src/data/blogPosts';
import { Tag } from '../../../src/components/Tag';
import { CodeBlock } from '../../../src/components/ArticleBlockquote';
import { H2 } from '../../../src/components/ArticleHeading';
import { Paragraph, LeadParagraph } from '../../../src/components/ArticleParagraph';
import { UL, LI } from '../../../src/components/ArticleList';
import { FileExamplesList } from '../../../src/components/FileExamplesList';
import { FileExampleManager } from '../../../src/data/fileExamples';
import { BlogPostClient } from '../../../src/components/BlogPostClient';
import { PageHeader } from '../../../src/components/PageHeader';
import { Section } from '../../../src/components/Section';
import { Reveal } from '../../../src/components/Reveal';
import * as React from "react";
import { notFound } from "next/navigation";
import { blogPosts } from "../../../src/data/blogPosts";
import { Tag } from "../../../src/components/Tag";
import { CodeBlock } from "../../../src/components/ArticleBlockquote";
import { H2 } from "../../../src/components/ArticleHeading";
import {
Paragraph,
LeadParagraph,
} from "../../../src/components/ArticleParagraph";
import { UL, LI } from "../../../src/components/ArticleList";
import { FileExamplesList } from "../../../src/components/FileExamplesList";
import { FileExampleManager } from "../../../src/data/fileExamples";
import { BlogPostClient } from "../../../src/components/BlogPostClient";
import { PageHeader } from "../../../src/components/PageHeader";
import { Section } from "../../../src/components/Section";
export async function generateStaticParams() {
return blogPosts.map((post) => ({
@@ -19,7 +21,11 @@ export async function generateStaticParams() {
}));
}
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = blogPosts.find((p) => p.slug === slug);
@@ -27,17 +33,23 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
notFound();
}
const formattedDate = new Date(post.date).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
const formattedDate = new Date(post.date).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
const wordCount = post.description.split(/\s+/).length + 100;
const readingTime = Math.max(1, Math.ceil(wordCount / 200));
const showFileExamples = post.tags?.some(tag =>
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
const showFileExamples = post.tags?.some((tag) =>
[
"architecture",
"design-patterns",
"system-design",
"docker",
"deployment",
].includes(tag),
);
// Load file examples for the post
@@ -59,10 +71,10 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
<div className="flex flex-col gap-24 py-12 md:py-24 overflow-hidden">
<BlogPostClient readingTime={readingTime} title={post.title} />
<PageHeader
<PageHeader
title={post.title}
description={post.description}
backLink={{ href: '/blog', label: 'Zurück zum Blog' }}
backLink={{ href: "/blog", label: "Zurück zum Blog" }}
backgroundSymbol="B"
/>
@@ -83,14 +95,16 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
</div>
)}
{slug === 'first-note' && (
{slug === "first-note" && (
<>
<LeadParagraph>
This blog is a public notebook. It's where I document things I learn, problems I solve, and tools I test.
This blog is a public notebook. It's where I document things I
learn, problems I solve, and tools I test.
</LeadParagraph>
<H2>Why write in public?</H2>
<Paragraph>
I forget things. Writing them down helps. Making them public helps me think more clearly and might help someone else.
I forget things. Writing them down helps. Making them public
helps me think more clearly and might help someone else.
</Paragraph>
<H2>What to expect</H2>
<UL>
@@ -101,91 +115,114 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
</UL>
</>
)}
{slug === 'debugging-tips' && (
{slug === "debugging-tips" && (
<>
<LeadParagraph>
Sometimes the simplest debugging tool is the best one. Print statements get a bad reputation, but they're often exactly what you need.
Sometimes the simplest debugging tool is the best one. Print
statements get a bad reputation, but they're often exactly
what you need.
</LeadParagraph>
<H2>Why print statements work</H2>
<Paragraph>
Debuggers are powerful, but they change how your code runs. Print statements don't.
Debuggers are powerful, but they change how your code runs.
Print statements don't.
</Paragraph>
<CodeBlock language="python" showLineNumbers={true}>
{`def process_data(data):
{`def process_data(data):
print(f"Processing {len(data)} items")
result = expensive_operation(data)
print(f"Operation result: {result}")
return result`}
</CodeBlock>
<H2>Complete examples</H2>
<Paragraph>
Here are some practical file examples you can copy and download. These include proper error handling and logging.
Here are some practical file examples you can copy and
download. These include proper error handling and logging.
</Paragraph>
<div className="my-8">
<FileExamplesList groups={groups} />
</div>
</>
)}
{slug === 'architecture-patterns' && (
{slug === "architecture-patterns" && (
<>
<LeadParagraph>
Good software architecture is about making the right decisions early. Here are some patterns I've found useful in production systems.
Good software architecture is about making the right decisions
early. Here are some patterns I've found useful in production
systems.
</LeadParagraph>
<H2>Repository Pattern</H2>
<Paragraph>
The repository pattern provides a clean separation between your business logic and data access layer. It makes your code more testable and maintainable.
The repository pattern provides a clean separation between
your business logic and data access layer. It makes your code
more testable and maintainable.
</Paragraph>
<H2>Service Layer</H2>
<Paragraph>
Services orchestrate business logic and coordinate between repositories and domain events. They keep your controllers thin and your business rules organized.
Services orchestrate business logic and coordinate between
repositories and domain events. They keep your controllers
thin and your business rules organized.
</Paragraph>
<H2>Domain Events</H2>
<Paragraph>
Domain events help you decouple components and react to changes in your system. They're essential for building scalable, event-driven architectures.
Domain events help you decouple components and react to
changes in your system. They're essential for building
scalable, event-driven architectures.
</Paragraph>
<H2>Complete examples</H2>
<Paragraph>
These TypeScript examples demonstrate modern architecture patterns for scalable applications. You can copy them directly into your project.
These TypeScript examples demonstrate modern architecture
patterns for scalable applications. You can copy them directly
into your project.
</Paragraph>
<div className="my-8">
<FileExamplesList groups={groups} />
</div>
</>
)}
{slug === 'docker-deployment' && (
{slug === "docker-deployment" && (
<>
<LeadParagraph>
Docker has become the standard for containerizing applications. Here's how to set up production-ready deployments that are secure, efficient, and maintainable.
Docker has become the standard for containerizing
applications. Here's how to set up production-ready
deployments that are secure, efficient, and maintainable.
</LeadParagraph>
<H2>Multi-stage builds</H2>
<Paragraph>
Multi-stage builds keep your production images small and secure by separating build and runtime environments. This reduces attack surface and speeds up deployments.
Multi-stage builds keep your production images small and
secure by separating build and runtime environments. This
reduces attack surface and speeds up deployments.
</Paragraph>
<H2>Health checks and monitoring</H2>
<Paragraph>
Proper health checks ensure your containers are running correctly. Combined with restart policies, this gives you resilient, self-healing deployments.
Proper health checks ensure your containers are running
correctly. Combined with restart policies, this gives you
resilient, self-healing deployments.
</Paragraph>
<H2>Orchestration with Docker Compose</H2>
<Paragraph>
Docker Compose makes it easy to manage multi-service applications in development and production. Define services, networks, and volumes in a single file.
Docker Compose makes it easy to manage multi-service
applications in development and production. Define services,
networks, and volumes in a single file.
</Paragraph>
<H2>Complete examples</H2>
<Paragraph>
These Docker configurations are production-ready. Use them as a starting point for your own deployments.
These Docker configurations are production-ready. Use them as
a starting point for your own deployments.
</Paragraph>
<div className="my-8">
<FileExamplesList groups={groups} />
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,88 +1,105 @@
'use client';
"use client";
import React from 'react';
import { PageHeader } from '../../src/components/PageHeader';
import { Section } from '../../src/components/Section';
import { Reveal } from '../../src/components/Reveal';
import { H3, LeadText, Label } from '../../src/components/Typography';
import { BackgroundGrid, Card, Container } from '../../src/components/Layout';
import { MotionButton } from '../../src/components/Button';
import Image from 'next/image';
import React from "react";
import { PageHeader } from "../../src/components/PageHeader";
import { Section } from "../../src/components/Section";
import { Reveal } from "../../src/components/Reveal";
import { H3, LeadText, Label } from "../../src/components/Typography";
import { BackgroundGrid, Card } from "../../src/components/Layout";
import { MotionButton } from "../../src/components/Button";
import Image from "next/image";
export default function CaseStudiesPage() {
return (
<div className="flex flex-col bg-white overflow-hidden relative min-h-screen">
<BackgroundGrid />
return (
<div className="flex flex-col bg-white overflow-hidden relative min-h-screen">
<BackgroundGrid />
<PageHeader
title={<>Case Studies: <br /><span className="text-slate-200">Qualität in jedem Detail.</span></>}
description="Ein Blick hinter die Kulissen ausgewählter Projekte. Von der ersten Idee bis zum fertigen Hochleistungssystem."
backLink={{ href: '/', label: 'Zurück' }}
backgroundSymbol="C"
/>
<PageHeader
title={
<>
Case Studies: <br />
<span className="text-slate-200">Qualität in jedem Detail.</span>
</>
}
description="Ein Blick hinter die Kulissen ausgewählter Projekte. Von der ersten Idee bis zum fertigen Hochleistungssystem."
backLink={{ href: "/", label: "Zurück" }}
backgroundSymbol="C"
/>
<Section number="01" title="Projekte" borderTop>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
<Reveal>
<Card variant="white" className="group overflow-hidden">
<div className="aspect-video relative overflow-hidden rounded-xl mb-8 bg-slate-100 border border-slate-100">
{/* We'll use a placeholder or a screenshot if available.
<Section number="01" title="Projekte" borderTop>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
<Reveal>
<Card variant="white" className="group overflow-hidden">
<div className="aspect-video relative overflow-hidden rounded-xl mb-8 bg-slate-100 border border-slate-100">
{/* We'll use a placeholder or a screenshot if available.
Since we have the cloned site, we could technically iframe a preview here too,
but a static image or a styled div is more standard for a card. */}
<div className="absolute inset-0 flex items-center justify-center bg-[#0117bf] transition-transform duration-700 group-hover:scale-105 p-12">
<Image
src="/showcase/klz-cables/assets/img/white_logo_transparent_background.svg"
alt="KLZ Cables Logo"
width={200}
height={200}
className="w-full h-auto max-w-[240px]"
/>
</div>
</div>
<div className="space-y-4">
<Label>Infrastructure & Energy</Label>
<H3 className="group-hover:text-slate-900 transition-colors">KLZ Cables Digitaler Netzbau</H3>
<LeadText className="text-base line-clamp-3">
Wie wir eine komplexe WordPress-Struktur in ein performantes, sauberes und langlebiges Web-System verwandelt haben. Fokus auf Performance, SEO und Benutzerführung.
</LeadText>
<div className="pt-4">
<MotionButton href="/case-studies/klz-cables">
Case Study lesen
</MotionButton>
</div>
</div>
</Card>
</Reveal>
<Reveal delay={0.2}>
<div className="h-full flex flex-col justify-center border-2 border-dashed border-slate-100 rounded-3xl p-12 text-center space-y-4">
<Label>Demnächst</Label>
<H3 className="text-slate-200">Weitere Projekte sind in Arbeit.</H3>
<LeadText className="text-base italic">
Ich dokumentiere gerade weitere spannende Projekte aus den Bereichen SaaS, E-Commerce und Systemarchitektur.
</LeadText>
</div>
</Reveal>
<div className="absolute inset-0 flex items-center justify-center bg-[#0117bf] transition-transform duration-700 group-hover:scale-105 p-12">
<Image
src="/showcase/klz-cables/assets/img/white_logo_transparent_background.svg"
alt="KLZ Cables Logo"
width={200}
height={200}
className="w-full h-auto max-w-[240px]"
/>
</div>
</Section>
</div>
<Section number="02" title="Philosophie" borderTop variant="gray">
<div className="max-w-3xl space-y-8">
<Reveal>
<H3 className="text-4xl leading-tight">
Warum ich Case Studies zeige? <br />
<span className="text-slate-200">Weil Code mehr als Text ist.</span>
</H3>
</Reveal>
<Reveal delay={0.2}>
<LeadText className="text-xl">
In diesen Case Studies geht es nicht nur um bunte Bilder. Es geht um die technischen Entscheidungen, die ein Projekt erfolgreich machen. Schnelle Ladezeiten, SEO-Exzellenz und wartbarer Code sind keine Zufälle, sondern das Ergebnis von präziser Planung.
</LeadText>
</Reveal>
<div className="space-y-4">
<Label>Infrastructure & Energy</Label>
<H3 className="group-hover:text-slate-900 transition-colors">
KLZ Cables Digitaler Netzbau
</H3>
<LeadText className="text-base line-clamp-3">
Wie wir eine komplexe WordPress-Struktur in ein performantes,
sauberes und langlebiges Web-System verwandelt haben. Fokus
auf Performance, SEO und Benutzerführung.
</LeadText>
<div className="pt-4">
<MotionButton href="/case-studies/klz-cables">
Case Study lesen
</MotionButton>
</div>
</Section>
</div>
</Card>
</Reveal>
<Reveal delay={0.2}>
<div className="h-full flex flex-col justify-center border-2 border-dashed border-slate-100 rounded-3xl p-12 text-center space-y-4">
<Label>Demnächst</Label>
<H3 className="text-slate-200">
Weitere Projekte sind in Arbeit.
</H3>
<LeadText className="text-base italic">
Ich dokumentiere gerade weitere spannende Projekte aus den
Bereichen SaaS, E-Commerce und Systemarchitektur.
</LeadText>
</div>
</Reveal>
</div>
);
</Section>
<Section number="02" title="Philosophie" borderTop variant="gray">
<div className="max-w-3xl space-y-8">
<Reveal>
<H3 className="text-4xl leading-tight">
Warum ich Case Studies zeige? <br />
<span className="text-slate-200">
Weil Code mehr als Text ist.
</span>
</H3>
</Reveal>
<Reveal delay={0.2}>
<LeadText className="text-xl">
In diesen Case Studies geht es nicht nur um bunte Bilder. Es geht
um die technischen Entscheidungen, die ein Projekt erfolgreich
machen. Schnelle Ladezeiten, SEO-Exzellenz und wartbarer Code sind
keine Zufälle, sondern das Ergebnis von präziser Planung.
</LeadText>
</Reveal>
</div>
</Section>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { CheerioCrawler, RequestQueue } from "crawlee";
import { CheerioCrawler } from "crawlee";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import { existsSync } from "node:fs";
@@ -1055,7 +1055,7 @@ ${JSON.stringify({ facts, strategy, ia, positionsData }, null, 2)}
finalState.sitemap = finalState.sitemap.sitemap;
else {
const entries = Object.entries(finalState.sitemap);
if (entries.every(([_, v]) => Array.isArray(v))) {
if (entries.every(([__, v]) => Array.isArray(v))) {
finalState.sitemap = entries.map(([category, pages]) => ({
category,
pages,

View File

@@ -1,322 +1,398 @@
import { chromium, type Page } from 'playwright';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import axios from 'axios';
import { chromium } from "playwright";
import path from "node:path";
import { fileURLToPath } from "node:url";
import fs from "node:fs";
import axios from "axios";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
const USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
function sanitizePath(rawPath: string) {
return rawPath.split('/').map(p => p.replace(/[^a-z0-9._-]/gi, '_')).join('/');
return rawPath
.split("/")
.map((p) => p.replace(/[^a-z0-9._-]/gi, "_"))
.join("/");
}
async function downloadFile(url: string, assetsDir: string) {
if (url.startsWith('//')) url = `https:${url}`;
if (!url.startsWith('http')) return null;
if (url.startsWith("//")) url = `https:${url}`;
if (!url.startsWith("http")) return null;
try {
const u = new URL(url);
// Create a collision-resistant local path
const relPath = sanitizePath(u.hostname + u.pathname);
const dest = path.join(assetsDir, relPath);
try {
const u = new URL(url);
// Create a collision-resistant local path
const relPath = sanitizePath(u.hostname + u.pathname);
const dest = path.join(assetsDir, relPath);
if (fs.existsSync(dest)) return `./assets/${relPath}`;
if (fs.existsSync(dest)) return `./assets/${relPath}`;
const res = await axios.get(url, {
responseType: 'arraybuffer',
headers: { 'User-Agent': USER_AGENT },
timeout: 15000,
validateStatus: () => true
});
const res = await axios.get(url, {
responseType: "arraybuffer",
headers: { "User-Agent": USER_AGENT },
timeout: 15000,
validateStatus: () => true,
});
if (res.status !== 200) return null;
if (res.status !== 200) return null;
if (!fs.existsSync(path.dirname(dest))) fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.from(res.data));
return `./assets/${relPath}`;
} catch {
return null; // Fail silently, proceed with original URL
}
if (!fs.existsSync(path.dirname(dest)))
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.from(res.data));
return `./assets/${relPath}`;
} catch {
return null; // Fail silently, proceed with original URL
}
}
async function processCssRecursively(cssContent: string, cssUrl: string, assetsDir: string, urlMap: Record<string, string>, depth = 0) {
if (depth > 5) return cssContent;
async function processCssRecursively(
cssContent: string,
cssUrl: string,
assetsDir: string,
urlMap: Record<string, string>,
depth = 0,
) {
if (depth > 5) return cssContent;
// Capture both standard url(...) and @import url(...)
const urlRegex = /(?:url\(["']?|@import\s+["'])([^"'\)]+)["']?\)?/gi;
let match;
let newContent = cssContent;
// Capture both standard url(...) and @import url(...)
const urlRegex = /(?:url\(["']?|@import\s+["'])([^"'\)]+)["']?\)?/gi;
let match;
let newContent = cssContent;
while ((match = urlRegex.exec(cssContent)) !== null) {
const originalUrl = match[1];
if (originalUrl.startsWith('data:') || originalUrl.startsWith('blob:')) continue;
while ((match = urlRegex.exec(cssContent)) !== null) {
const originalUrl = match[1];
if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
continue;
try {
const absUrl = new URL(originalUrl, cssUrl).href;
const local = await downloadFile(absUrl, assetsDir);
try {
const absUrl = new URL(originalUrl, cssUrl).href;
const local = await downloadFile(absUrl, assetsDir);
if (local) {
// Calculate relative path from CSS file to Asset
const u = new URL(cssUrl);
const cssPath = u.hostname + u.pathname;
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
if (local) {
// Calculate relative path from CSS file to Asset
const u = new URL(cssUrl);
const cssPath = u.hostname + u.pathname;
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
// We need to route from the folder containing the CSS to the asset
const rel = path.relative(path.dirname(sanitizePath(cssPath)), sanitizePath(assetPath));
// We need to route from the folder containing the CSS to the asset
const rel = path.relative(
path.dirname(sanitizePath(cssPath)),
sanitizePath(assetPath),
);
// Replace strictly the URL part
newContent = newContent.split(originalUrl).join(rel);
urlMap[absUrl] = local;
}
} catch { }
// Replace strictly the URL part
newContent = newContent.split(originalUrl).join(rel);
urlMap[absUrl] = local;
}
} catch {
// Ignore URL resolution errors
}
return newContent;
}
return newContent;
}
async function run() {
const rawUrl = process.argv[2];
if (!rawUrl) {
console.error('Usage: npm run clone-page <url>');
process.exit(1);
const rawUrl = process.argv[2];
if (!rawUrl) {
console.error("Usage: npm run clone-page <url>");
process.exit(1);
}
const targetUrl = rawUrl.trim();
const urlObj = new URL(targetUrl);
// Setup Output Directories
const domainSlug = urlObj.hostname.replace("www.", "");
const domainDir = path.resolve(__dirname, `../public/showcase/${domainSlug}`);
const assetsDir = path.join(domainDir, "assets");
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
let pageSlug = urlObj.pathname.split("/").filter(Boolean).join("-");
if (!pageSlug) pageSlug = "index";
const htmlFilename = `${pageSlug}.html`;
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
const browser = await chromium.launch({ headless: true });
// Start with a standard viewport, we will resize widely later
const context = await browser.newContext({
userAgent: USER_AGENT,
viewport: { width: 1920, height: 1080 },
});
const page = await context.newPage();
const urlMap: Record<string, string> = {};
const foundAssets = new Set<string>();
// 1. Live Network Interception
page.on("response", (response) => {
const url = response.url();
if (response.status() === 200) {
// Capture anything that looks like a static asset
if (
url.match(
/\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i,
)
) {
foundAssets.add(url);
}
}
const targetUrl = rawUrl.trim();
const urlObj = new URL(targetUrl);
});
// Setup Output Directories
const domainSlug = urlObj.hostname.replace('www.', '');
const domainDir = path.resolve(__dirname, `../public/showcase/${domainSlug}`);
const assetsDir = path.join(domainDir, 'assets');
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
try {
console.log("🌐 Loading page (Waiting for Network Idle)...");
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 90000 });
let pageSlug = urlObj.pathname.split('/').filter(Boolean).join('-');
if (!pageSlug) pageSlug = 'index';
const htmlFilename = `${pageSlug}.html`;
console.log(
'🌊 Executing "Scroll Wave" to trigger all lazy loaders naturally...',
);
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 400;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
const browser = await chromium.launch({ headless: true });
// Start with a standard viewport, we will resize widely later
const context = await browser.newContext({ userAgent: USER_AGENT, viewport: { width: 1920, height: 1080 } });
const page = await context.newPage();
const urlMap: Record<string, string> = {};
const foundAssets = new Set<string>();
// 1. Live Network Interception
page.on('response', response => {
const url = response.url();
if (response.status() === 200) {
// Capture anything that looks like a static asset
if (url.match(/\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i)) {
foundAssets.add(url);
}
}
if (totalHeight >= scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0); // Reset to top
resolve(true);
}
}, 100);
});
});
try {
console.log('🌐 Loading page (Waiting for Network Idle)...');
await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 90000 });
console.log(
'📐 Expanding Viewport to "Giant Mode" for final asset capture...',
);
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
console.log('🌊 Executing "Scroll Wave" to trigger all lazy loaders naturally...');
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 400;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
// Final settlement wait
await page.waitForTimeout(3000);
if (totalHeight >= scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0); // Reset to top
resolve(true);
}
}, 100);
console.log("💧 Final DOM Hydration & Sanitization...");
await page.evaluate(() => {
// A. Deterministic Attribute Hydration (Generic)
// Scours every element for attributes that look like asset URLs and promotes them
const assetPattern =
/\.(jpg|jpeg|png|gif|svg|webp|mp4|webm|woff2?|ttf|otf)/i;
document.querySelectorAll("*").forEach((el) => {
// 0. Skip Meta/Head/Script/Style/SVG tags for attribute promotion
if (
["META", "LINK", "HEAD", "SCRIPT", "STYLE", "SVG", "PATH"].includes(
el.tagName,
)
)
return;
// 1. Force Visibility (Anti-Flicker)
const htmlEl = el as HTMLElement;
const style = window.getComputedStyle(htmlEl);
if (style.opacity === "0" || style.visibility === "hidden") {
htmlEl.style.setProperty("opacity", "1", "important");
htmlEl.style.setProperty("visibility", "visible", "important");
}
// 2. Promote Data Attributes
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const val = attr.value;
if (
assetPattern.test(val) ||
name.includes("src") ||
name.includes("image")
) {
// Standard Image/Video/Source promotion
if (el.tagName === "IMG") {
const img = el as HTMLImageElement;
if (name.includes("srcset")) img.srcset = val;
else if (!img.src || img.src.includes("data:")) img.src = val;
}
if (el.tagName === "SOURCE") {
const source = el as HTMLSourceElement;
if (name.includes("srcset")) source.srcset = val;
}
if (el.tagName === "VIDEO" || el.tagName === "AUDIO") {
const media = el as HTMLMediaElement;
if (!media.src) media.src = val;
}
// Background Image Promotion
if (val.match(/^(https?:\/\/|\/\/|\/)/) && !name.includes("href")) {
const bg = htmlEl.style.backgroundImage;
if (!bg || bg === "none") {
htmlEl.style.backgroundImage = `url('${val}')`;
}
}
}
}
});
// B. Ensure basic structural elements are visible post-scroll
const body = document.body;
if (body) {
body.style.setProperty("opacity", "1", "important");
body.style.setProperty("visibility", "visible", "important");
}
});
console.log("⏳ Waiting for network idle...");
await page.waitForLoadState("networkidle");
// 1.5 FINAL SETTLEMENT: Let any scroll-triggered JS finish
await page.waitForTimeout(1000);
// 2. Static Snapshot
let content = await page.content();
// 3. Post-Snapshot Asset Discovery (Regex)
// Catches assets that never triggered a network request but exist in the markup
const regexPatterns = [
/(?:src|href|url|data-[a-z-]+|srcset)=["']([^"'<>\s]+?\.(?:css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)(?:\?[^"']*)?)["']/gi,
// Capture CSS url() inside style blocks
/url\(["']?([^"'\)]+)["']?\)/gi,
];
for (const pattern of regexPatterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
try {
foundAssets.add(new URL(match[1], targetUrl).href);
} catch {
// Ignore invalid URLs in content
}
}
}
// Specific srcset parsing
const srcsetRegex = /[a-z0-9-]+srcset=["']([^"']+)["']/gi;
let match;
while ((match = srcsetRegex.exec(content)) !== null) {
match[1].split(",").forEach((rule) => {
const parts = rule.trim().split(/\s+/);
if (parts[0] && !parts[0].startsWith("data:")) {
try {
foundAssets.add(new URL(parts[0], targetUrl).href);
} catch {
// Ignore invalid srcset URLs
}
}
});
}
console.log(`🔍 Processing ${foundAssets.size} discovered assets...`);
// 4. Download & Map
for (const url of foundAssets) {
const local = await downloadFile(url, assetsDir);
if (local) {
urlMap[url] = local;
const clean = url.split("?")[0];
urlMap[clean] = local;
// Handle CSS recursively
if (clean.endsWith(".css")) {
try {
const { data } = await axios.get(url, {
headers: { "User-Agent": USER_AGENT },
});
});
console.log('📐 Expanding Viewport to "Giant Mode" for final asset capture...');
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
// Final settlement wait
await page.waitForTimeout(3000);
console.log('💧 Final DOM Hydration & Sanitization...');
await page.evaluate(() => {
// A. Deterministic Attribute Hydration (Generic)
// Scours every element for attributes that look like asset URLs and promotes them
const assetPattern = /\.(jpg|jpeg|png|gif|svg|webp|mp4|webm|woff2?|ttf|otf)/i;
document.querySelectorAll('*').forEach(el => {
// 0. Skip Meta/Head/Script/Style/SVG tags for attribute promotion
if (['META', 'LINK', 'HEAD', 'SCRIPT', 'STYLE', 'SVG', 'PATH'].includes(el.tagName)) return;
// 1. Force Visibility (Anti-Flicker)
const htmlEl = el as HTMLElement;
const style = window.getComputedStyle(htmlEl);
if (style.opacity === '0' || style.visibility === 'hidden') {
htmlEl.style.setProperty('opacity', '1', 'important');
htmlEl.style.setProperty('visibility', 'visible', 'important');
}
// 2. Promote Data Attributes
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const val = attr.value;
if (assetPattern.test(val) || name.includes('src') || name.includes('image')) {
// Standard Image/Video/Source promotion
if (el.tagName === 'IMG') {
const img = el as HTMLImageElement;
if (name.includes('srcset')) img.srcset = val;
else if (!img.src || img.src.includes('data:')) img.src = val;
}
if (el.tagName === 'SOURCE') {
const source = el as HTMLSourceElement;
if (name.includes('srcset')) source.srcset = val;
}
if (el.tagName === 'VIDEO' || el.tagName === 'AUDIO') {
const media = el as HTMLMediaElement;
if (!media.src) media.src = val;
}
// Background Image Promotion
if (val.match(/^(https?:\/\/|\/\/|\/)/) && !name.includes('href')) {
const bg = htmlEl.style.backgroundImage;
if (!bg || bg === 'none') {
htmlEl.style.backgroundImage = `url('${val}')`;
}
}
}
}
});
// B. Ensure basic structural elements are visible post-scroll
const body = document.body;
if (body) {
body.style.setProperty('opacity', '1', 'important');
body.style.setProperty('visibility', 'visible', 'important');
}
});
console.log('⏳ Waiting for network idle...');
await page.waitForLoadState('networkidle');
// 1.5 FINAL SETTLEMENT: Let any scroll-triggered JS finish
await page.waitForTimeout(1000);
// 2. Static Snapshot
let content = await page.content();
// 3. Post-Snapshot Asset Discovery (Regex)
// Catches assets that never triggered a network request but exist in the markup
const regexPatterns = [
/(?:src|href|url|data-[a-z-]+|srcset)=["']([^"'<>\s]+?\.(?:css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)(?:\?[^"']*)?)["']/gi,
// Capture CSS url() inside style blocks
/url\(["']?([^"'\)]+)["']?\)/gi
];
for (const pattern of regexPatterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
try { foundAssets.add(new URL(match[1], targetUrl).href); } catch { }
}
// Process CSS and save it
const processedCss = await processCssRecursively(
data,
url,
assetsDir,
urlMap,
);
const relPath = sanitizePath(
new URL(url).hostname + new URL(url).pathname,
);
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
} catch {
// Ignore CSS fetch/process errors
}
}
}
}
// Specific srcset parsing
const srcsetRegex = /[a-z0-9-]+srcset=["']([^"']+)["']/gi;
let match;
while ((match = srcsetRegex.exec(content)) !== null) {
match[1].split(',').forEach(rule => {
const parts = rule.trim().split(/\s+/);
if (parts[0] && !parts[0].startsWith('data:')) {
try { foundAssets.add(new URL(parts[0], targetUrl).href); } catch { }
}
});
console.log("🛠️ Finalizing Static Mirror...");
let finalContent = content;
// A. Apply URL Map Replacements
// Longer paths first to prevent partial replacement errors
const sortedUrls = Object.keys(urlMap).sort((a, b) => b.length - a.length);
if (sortedUrls.length > 0) {
const escaped = sortedUrls.map((u) =>
u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
);
// Create a massive regex for single-pass replacement
const masterRegex = new RegExp(`(${escaped.join("|")})`, "g");
finalContent = finalContent.replace(
masterRegex,
(match) => urlMap[match] || match,
);
}
// B. Global Root-Relative Path Cleanup
// Catches things like /wp-content/ that weren't distinct assets or were missed
const commonDirs = [
"/wp-content/",
"/wp-includes/",
"/assets/",
"/static/",
"/images/",
];
for (const dir of commonDirs) {
const localDir = `./assets/${urlObj.hostname}${dir}`;
finalContent = finalContent.split(`"${dir}`).join(`"${localDir}`);
finalContent = finalContent.split(`'${dir}`).join(`'${localDir}`);
finalContent = finalContent.split(`(${dir}`).join(`(${localDir}`);
}
// C. Domain Nuke
// Replace absolute links to the original domain with relative or #
const domainPattern = new RegExp(
`https?://(www\\.)?${urlObj.hostname.replace(/\./g, "\\.")}[^"']*`,
"gi",
);
// We carefully only replace if it looks like a resource link, or neutralize if it's a navigation link
// For simplicity and "solidness", we'll rely on the specific replacements above first.
// This catch-all nuke ensures we don't leak requests.
// Convert remaining absolute domain links to relative .
finalContent = finalContent.replace(domainPattern, (match) => {
// If we have a map for it, it should have been replaced.
// If not, it's likely a navigation link or an uncaptured asset.
// Safe fallback:
return "./";
});
// D. Static Stability & Cleanup
// Remove tracking/analytics/lazy-load scripts that ruins stability
finalContent = finalContent.replace(
/<script\b[^>]*>([\s\S]*?)<\/script>/gi,
(match, content) => {
const lower = content.toLowerCase();
if (
lower.includes("google-analytics") ||
lower.includes("gtag") ||
lower.includes("fbq") ||
lower.includes("lazy") ||
lower.includes("tracker")
) {
return "";
}
return match;
},
);
console.log(`🔍 Processing ${foundAssets.size} discovered assets...`);
// 4. Download & Map
for (const url of foundAssets) {
const local = await downloadFile(url, assetsDir);
if (local) {
urlMap[url] = local;
const clean = url.split('?')[0];
urlMap[clean] = local;
// Handle CSS recursively
if (clean.endsWith('.css')) {
try {
const { data } = await axios.get(url, { headers: { 'User-Agent': USER_AGENT } });
// Process CSS and save it
const processedCss = await processCssRecursively(data, url, assetsDir, urlMap);
const relPath = sanitizePath(new URL(url).hostname + new URL(url).pathname);
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
} catch { }
}
}
}
console.log('🛠️ Finalizing Static Mirror...');
let finalContent = content;
// A. Apply URL Map Replacements
// Longer paths first to prevent partial replacement errors
const sortedUrls = Object.keys(urlMap).sort((a, b) => b.length - a.length);
if (sortedUrls.length > 0) {
const escaped = sortedUrls.map(u => u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
// Create a massive regex for single-pass replacement
const masterRegex = new RegExp(`(${escaped.join('|')})`, 'g');
finalContent = finalContent.replace(masterRegex, (match) => urlMap[match] || match);
}
// B. Global Root-Relative Path Cleanup
// Catches things like /wp-content/ that weren't distinct assets or were missed
const commonDirs = ['/wp-content/', '/wp-includes/', '/assets/', '/static/', '/images/'];
for (const dir of commonDirs) {
const localDir = `./assets/${urlObj.hostname}${dir}`;
finalContent = finalContent.split(`"${dir}`).join(`"${localDir}`);
finalContent = finalContent.split(`'${dir}`).join(`'${localDir}`);
finalContent = finalContent.split(`(${dir}`).join(`(${localDir}`);
}
// C. Domain Nuke
// Replace absolute links to the original domain with relative or #
const domainPattern = new RegExp(`https?://(www\\.)?${urlObj.hostname.replace(/\./g, '\\.')}[^"']*`, 'gi');
// We carefully only replace if it looks like a resource link, or neutralize if it's a navigation link
// For simplicity and "solidness", we'll rely on the specific replacements above first.
// This catch-all nuke ensures we don't leak requests.
// Convert remaining absolute domain links to relative .
finalContent = finalContent.replace(domainPattern, (match) => {
// If we have a map for it, it should have been replaced.
// If not, it's likely a navigation link or an uncaptured asset.
// Safe fallback:
return './';
});
// D. Static Stability & Cleanup
// Remove tracking/analytics/lazy-load scripts that ruins stability
finalContent = finalContent.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (match, content) => {
const lower = content.toLowerCase();
if (lower.includes('google-analytics') ||
lower.includes('gtag') ||
lower.includes('fbq') ||
lower.includes('lazy') ||
lower.includes('tracker')) {
return '';
}
return match;
});
// E. CSS Injections for Stability
const headEnd = finalContent.indexOf('</head>');
if (headEnd > -1) {
const stabilityCss = `
// E. CSS Injections for Stability
const headEnd = finalContent.indexOf("</head>");
if (headEnd > -1) {
const stabilityCss = `
<style>
/* UNIVERSAL CLONE STABILIZATION */
* {
@@ -340,19 +416,21 @@ async function run() {
cursor: default;
}
</style>`;
finalContent = finalContent.slice(0, headEnd) + stabilityCss + finalContent.slice(headEnd);
}
// Save
const finalPath = path.join(domainDir, htmlFilename);
fs.writeFileSync(finalPath, finalContent);
console.log(`✅ SUCCESS: Cloned to ${finalPath}`);
} catch (err) {
console.error('❌ FATAL ERROR:', err);
} finally {
await browser.close();
finalContent =
finalContent.slice(0, headEnd) +
stabilityCss +
finalContent.slice(headEnd);
}
// Save
const finalPath = path.join(domainDir, htmlFilename);
fs.writeFileSync(finalPath, finalContent);
console.log(`✅ SUCCESS: Cloned to ${finalPath}`);
} catch (err) {
console.error("❌ FATAL ERROR:", err);
} finally {
await browser.close();
}
}
run();

View File

@@ -1,228 +1,223 @@
// @ts-ignore
import scrape from 'website-scraper';
import scrape from "website-scraper";
// @ts-ignore
import PuppeteerPlugin from 'website-scraper-puppeteer';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import PuppeteerPlugin from "website-scraper-puppeteer";
import path from "node:path";
import { fileURLToPath } from "node:url";
import fs from "node:fs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
class CustomFilenameGeneratorPlugin {
apply(registerAction: any) {
registerAction('generateFilename', ({ resource }: any) => {
const url = new URL(resource.url);
const ext = path.extname(url.pathname);
async function run() {
const targetUrl = process.argv[2];
if (!targetUrl) {
console.error("Usage: npm run clone-website <URL> [output-dir]");
process.exit(1);
}
// Clean the path
let safePath = url.pathname;
if (safePath.endsWith('/')) {
safePath += 'index.html';
} else if (!ext && !resource.isHtml()) {
// If no extension and not HTML, guess based on content type?
// But usually safe to leave as is or add extension if known.
} else if (!ext && resource.isHtml()) {
safePath += '.html';
const urlObj = new URL(targetUrl);
const domain = urlObj.hostname;
const safeDomain = domain.replace(/[^a-z0-9-]/gi, "_");
const outputDir = process.argv[3]
? path.resolve(process.cwd(), process.argv[3])
: path.resolve(__dirname, "../cloned-websites", safeDomain);
if (fs.existsSync(outputDir)) {
console.log(`Cleaning existing directory: ${outputDir}`);
fs.rmSync(outputDir, { recursive: true, force: true });
}
console.log(`🚀 Starting recursive clone of ${targetUrl}`);
console.log(`📂 Output: ${outputDir}`);
const options = {
urls: [targetUrl],
directory: outputDir,
recursive: true,
maxDepth: 5,
// Custom filename generation to avoid "https:/" folders
plugins: [
new PuppeteerPlugin({
launchOptions: {
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
],
},
scrollToBottom: { timeout: 10000, viewportN: 10 },
blockNavigation: false,
}),
new (class LoggerPlugin {
apply(registerAction: any) {
registerAction("onResourceSaved", ({ resource }: any) => {
console.log(` 💾 Saved: ${resource.url} -> ${resource.filename}`);
});
registerAction("onResourceError", ({ resource, error }: any) => {
console.error(` ❌ Error: ${resource.url} - ${error.message}`);
});
}
})(),
new (class FilenamePlugin {
apply(registerAction: any) {
registerAction("generateFilename", ({ resource }: any) => {
const u = new URL(resource.url);
let filename = u.pathname;
// normalize
if (filename.endsWith("/")) filename += "index.html";
else if (!path.extname(filename) && resource.url.includes(domain))
filename += "/index.html"; // Assume folder if internal link without ext
// If it's an external asset, put it in a separate folder
if (u.hostname !== domain) {
filename = `_external/${u.hostname}${filename}`;
}
// Handle query strings if needed (simplifying by ignoring them for static local files usually better,
// unless they determine content. For a clean clone, we usually ignore unique query params)
// But if the site relies on routing via query params (e.g. ?page=2), we might want to encode them.
// For now, let's keep it simple and clean.
// Sanitize filename
filename = filename
.split("/")
.map((part) => part.replace(/[^a-z0-9._-]/gi, "_"))
.join("/");
// Remove leading slash
if (safePath.startsWith('/')) safePath = safePath.substring(1);
if (filename.startsWith("/")) filename = filename.substring(1);
// Sanitization
safePath = safePath.replace(/[:*?"<>|]/g, '_');
// Handle "Unnamed page" by checking if empty
if (!filename || filename === "index.html")
return { filename: "index.html" };
// External assets go to a separate folder to avoid collision
// We can detect external by checking if the resource parent is different?
// Actually, simply using the hostname mapping is safer.
// However, the USER wants "local cloned pages".
// If we just use the path, we merge everything into one root.
// If there are collision (e.g. same path on different domains), this is bad.
// But typically we clone ONE site.
return { filename: safePath };
});
}
}
async function run() {
const targetUrl = process.argv[2];
if (!targetUrl) {
console.error('Usage: npm run clone-website <URL> [output-dir]');
process.exit(1);
}
const urlObj = new URL(targetUrl);
const domain = urlObj.hostname;
const safeDomain = domain.replace(/[^a-z0-9-]/gi, '_');
const outputDir = process.argv[3]
? path.resolve(process.cwd(), process.argv[3])
: path.resolve(__dirname, '../cloned-websites', safeDomain);
if (fs.existsSync(outputDir)) {
console.log(`Cleaning existing directory: ${outputDir}`);
fs.rmSync(outputDir, { recursive: true, force: true });
}
console.log(`🚀 Starting recursive clone of ${targetUrl}`);
console.log(`📂 Output: ${outputDir}`);
const options = {
urls: [targetUrl],
directory: outputDir,
recursive: true,
maxDepth: 5,
// Custom filename generation to avoid "https:/" folders
plugins: [
new PuppeteerPlugin({
launchOptions: {
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
},
scrollToBottom: { timeout: 10000, viewportN: 10 },
blockNavigation: false
}),
new class LoggerPlugin {
apply(registerAction: any) {
registerAction('onResourceSaved', ({ resource }: any) => {
console.log(` 💾 Saved: ${resource.url} -> ${resource.filename}`);
});
registerAction('onResourceError', ({ resource, error }: any) => {
console.error(` ❌ Error: ${resource.url} - ${error.message}`);
});
}
},
new class FilenamePlugin {
apply(registerAction: any) {
registerAction('generateFilename', ({ resource }: any) => {
const u = new URL(resource.url);
let filename = u.pathname;
// normalize
if (filename.endsWith('/')) filename += 'index.html';
else if (!path.extname(filename) && resource.url.includes(domain)) filename += '/index.html'; // Assume folder if internal link without ext
// If it's an external asset, put it in a separate folder
if (u.hostname !== domain) {
filename = `_external/${u.hostname}${filename}`;
}
// Sanitize filename
filename = filename.split('/').map(part => part.replace(/[^a-z0-9._-]/gi, '_')).join('/');
// Remove leading slash
if (filename.startsWith('/')) filename = filename.substring(1);
// Handle "Unnamed page" by checking if empty
if (!filename || filename === 'index.html') return { filename: 'index.html' };
return { filename };
});
}
}
],
urlFilter: (url: string) => {
const u = new URL(url);
const isTargetDomain = u.hostname === domain;
const isGoogleFonts = u.hostname.includes('fonts.googleapis.com') || u.hostname.includes('fonts.gstatic.com');
// Allow assets from anywhere
const isAsset = /\.(css|js|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|mp4|webm|ico|json|webp)$/i.test(u.pathname);
// Allow fonts/css from common CDNs if standard extension check fails
const isCommonAsset = u.pathname.includes('/css/') || u.pathname.includes('/js/') || u.pathname.includes('/static/') || u.pathname.includes('/assets/') || u.pathname.includes('/uploads/');
return isTargetDomain || isAsset || isCommonAsset || isGoogleFonts;
},
sources: [
{ selector: 'img', attr: 'src' },
{ selector: 'img', attr: 'srcset' },
{ selector: 'source', attr: 'src' },
{ selector: 'source', attr: 'srcset' },
{ selector: 'link[rel="stylesheet"]', attr: 'href' },
{ selector: 'link[rel="preload"]', attr: 'href' },
{ selector: 'link[rel="prefetch"]', attr: 'href' },
{ selector: 'script', attr: 'src' },
{ selector: 'video', attr: 'src' },
{ selector: 'video', attr: 'poster' },
{ selector: 'iframe', attr: 'src' },
{ selector: 'link[rel*="icon"]', attr: 'href' },
{ selector: 'link[rel="manifest"]', attr: 'href' },
{ selector: 'meta[property="og:image"]', attr: 'content' }
],
request: {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
}
return { filename };
});
}
};
})(),
],
try {
// @ts-ignore
const result = await scrape(options);
console.log(`\n✅ Successfully cloned ${result.length} resources to ${outputDir}`);
urlFilter: (url: string) => {
const u = new URL(url);
const isTargetDomain = u.hostname === domain;
const isGoogleFonts =
u.hostname.includes("fonts.googleapis.com") ||
u.hostname.includes("fonts.gstatic.com");
// Allow assets from anywhere
const isAsset =
/\.(css|js|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|mp4|webm|ico|json|webp)$/i.test(
u.pathname,
);
// Allow fonts/css from common CDNs if standard extension check fails
const isCommonAsset =
u.pathname.includes("/css/") ||
u.pathname.includes("/js/") ||
u.pathname.includes("/static/") ||
u.pathname.includes("/assets/") ||
u.pathname.includes("/uploads/");
// Post-processing: Sanitize HTML to remove Next.js hydration scripts
// This prevents the static site from trying to "hydrate" and breaking images/links
console.log('🧹 Sanitizing HTML files...');
sanitizeHtmlFiles(outputDir);
return isTargetDomain || isAsset || isCommonAsset || isGoogleFonts;
},
console.log(`open "${path.join(outputDir, 'index.html')}"`);
} catch (error) {
console.error('❌ Error cloning website:', error);
process.exit(1);
}
sources: [
{ selector: "img", attr: "src" },
{ selector: "img", attr: "srcset" },
{ selector: "source", attr: "src" },
{ selector: "source", attr: "srcset" },
{ selector: 'link[rel="stylesheet"]', attr: "href" },
{ selector: 'link[rel="preload"]', attr: "href" },
{ selector: 'link[rel="prefetch"]', attr: "href" },
{ selector: "script", attr: "src" },
{ selector: "video", attr: "src" },
{ selector: "video", attr: "poster" },
{ selector: "iframe", attr: "src" },
{ selector: 'link[rel*="icon"]', attr: "href" },
{ selector: 'link[rel="manifest"]', attr: "href" },
{ selector: 'meta[property="og:image"]', attr: "content" },
],
request: {
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
},
},
};
try {
// @ts-ignore
const result = await scrape(options);
console.log(
`\n✅ Successfully cloned ${result.length} resources to ${outputDir}`,
);
// Post-processing: Sanitize HTML to remove Next.js hydration scripts
// This prevents the static site from trying to "hydrate" and breaking images/links
console.log("🧹 Sanitizing HTML files...");
sanitizeHtmlFiles(outputDir);
console.log(`open "${path.join(outputDir, "index.html")}"`);
} catch (error) {
console.error("❌ Error cloning website:", error);
process.exit(1);
}
}
function sanitizeHtmlFiles(dir: string) {
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
if (fs.statSync(fullPath).isDirectory()) {
sanitizeHtmlFiles(fullPath);
} else if (file.endsWith('.html')) {
let content = fs.readFileSync(fullPath, 'utf8');
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
if (fs.statSync(fullPath).isDirectory()) {
sanitizeHtmlFiles(fullPath);
} else if (file.endsWith(".html")) {
let content = fs.readFileSync(fullPath, "utf8");
// Remove Next.js data script
content = content.replace(/<script id="__NEXT_DATA__"[\s\S]*?<\/script>/gi, '');
// Remove Next.js data script
content = content.replace(
/<script id="__NEXT_DATA__"[\s\S]*?<\/script>/gi,
"",
);
// Remove Next.js chunk scripts (hydration)
// match <script src="..._next/static/chunks..." ...
content = content.replace(/<script[^>]+src="[^"]*\/_next\/static\/chunks\/[^"]*"[^>]*><\/script>/gi, '');
content = content.replace(/<script[^>]+src="[^"]*\/_next\/static\/[^"]*Manifest\.js"[^>]*><\/script>/gi, '');
// Remove Next.js chunk scripts (hydration)
// match <script src="..._next/static/chunks..." ...
content = content.replace(
/<script[^>]+src="[^"]*\/_next\/static\/chunks\/[^"]*"[^>]*><\/script>/gi,
"",
);
content = content.replace(
/<script[^>]+src="[^"]*\/_next\/static\/[^"]*Manifest\.js"[^>]*><\/script>/gi,
"",
);
// Convert Breeze dynamic script/styles into actual tags if possible
// match <div class="breeze-scripts-load" ...>URL</div>
content = content.replace(/<div[^>]+class="breeze-scripts-load"[^>]*>([^<]+)<\/div>/gi, (match, url) => {
if (url.endsWith('.css')) return `<link rel="stylesheet" href="${url}">`;
return `<script src="${url}"></script>`;
});
// Convert Breeze dynamic script/styles into actual tags if possible
// match <div class="breeze-scripts-load" ...>URL</div>
content = content.replace(
/<div[^>]+class="breeze-scripts-load"[^>]*>([^<]+)<\/div>/gi,
(match, url) => {
if (url.endsWith(".css"))
return `<link rel="stylesheet" href="${url}">`;
return `<script src="${url}"></script>`;
},
);
// Inject Fonts (Fix for missing dynamic fonts)
// We inject Inter and Montserrat as safe defaults for industrial/modern sites
// Check specifically for a stylesheet link to google fonts
const hasGoogleFontStylesheet = /<link[^>]+rel="stylesheet"[^>]+href="[^"]*fonts\.googleapis\.com/i.test(content);
if (!hasGoogleFontStylesheet) {
const fontLink = `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&display=swap">`;
const styleBlock = `<style>
// Inject Fonts (Fix for missing dynamic fonts)
// We inject Inter and Montserrat as safe defaults for industrial/modern sites
// Check specifically for a stylesheet link to google fonts
const hasGoogleFontStylesheet =
/<link[^>]+rel="stylesheet"[^>]+href="[^"]*fonts\.googleapis\.com/i.test(
content,
);
if (!hasGoogleFontStylesheet) {
const fontLink = `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&display=swap">`;
const styleBlock = `<style>
:root { --main-font: 'Inter', sans-serif; --heading-font: 'Montserrat', sans-serif; }
body, .body-font, p, span, li, a { font-family: var(--main-font) !important; }
h1, h2, h3, h4, h5, h6, .title-font, .heading-font { font-family: var(--heading-font) !important; }
</style>`;
content = content.replace('</head>', `${fontLink}${styleBlock}</head>`);
}
content = content.replace("</head>", `${fontLink}${styleBlock}</head>`);
}
// Force column layout on product pages
if (content.includes('class="products')) {
const layoutScript = `
// Force column layout on product pages
if (content.includes('class="products')) {
const layoutScript = `
<script>
document.addEventListener('DOMContentLoaded', function() {
const products = document.querySelector('.products');
@@ -233,12 +228,12 @@ function sanitizeHtmlFiles(dir: string) {
}
});
</script>`;
content = content.replace('</body>', `${layoutScript}</body>`);
}
content = content.replace("</body>", `${layoutScript}</body>`);
}
fs.writeFileSync(fullPath, content);
}
fs.writeFileSync(fullPath, content);
}
}
}
run();

View File

@@ -1,8 +1,8 @@
import scrape from 'website-scraper';
import PuppeteerPlugin from 'website-scraper-puppeteer';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import scrape from "website-scraper";
import PuppeteerPlugin from "website-scraper-puppeteer";
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -11,45 +11,55 @@ const __dirname = path.dirname(__filename);
class PortfolioPlugin {
apply(registerAction: any) {
// 1. Add more sources before starting
registerAction('beforeStart', ({ options }: any) => {
registerAction("beforeStart", ({ options }: any) => {
if (!options.sources) options.sources = [];
options.sources.push({ selector: 'img', attr: 'data-nimg' });
options.sources.push({ selector: 'img', attr: 'data-src' });
options.sources.push({ selector: 'img', attr: 'data-srcset' });
options.sources.push({ selector: 'video', attr: 'poster' });
options.sources.push({ selector: 'source', attr: 'data-srcset' });
options.sources.push({ selector: '[style*="background-image"]', attr: 'style' });
options.sources.push({ selector: 'link[as="font"]', attr: 'href' });
options.sources.push({ selector: 'link[as="image"]', attr: 'href' });
options.sources.push({ selector: 'link[as="style"]', attr: 'href' });
options.sources.push({ selector: 'link[as="script"]', attr: 'href' });
options.sources.push({ selector: "img", attr: "data-nimg" });
options.sources.push({ selector: "img", attr: "data-src" });
options.sources.push({ selector: "img", attr: "data-srcset" });
options.sources.push({ selector: "video", attr: "poster" });
options.sources.push({ selector: "source", attr: "data-srcset" });
options.sources.push({
selector: '[style*="background-image"]',
attr: "style",
});
options.sources.push({ selector: 'link[as="font"]', attr: "href" });
options.sources.push({ selector: 'link[as="image"]', attr: "href" });
options.sources.push({ selector: 'link[as="style"]', attr: "href" });
options.sources.push({ selector: 'link[as="script"]', attr: "href" });
});
// 2. Sanitize filenames and handle Next.js optimized images
registerAction('generateFilename', ({ resource, filename }: any) => {
registerAction("generateFilename", ({ resource, filename }: any) => {
const url = resource.getUrl();
let result = filename;
// Handle Next.js optimized images: /_next/image?url=...&w=...
if (url.includes('/_next/image')) {
if (url.includes("/_next/image")) {
try {
const urlParams = new URL(url).searchParams;
const originalUrl = urlParams.get('url');
const originalUrl = urlParams.get("url");
if (originalUrl) {
const cleanPath = originalUrl.split('?')[0];
const ext = path.extname(cleanPath) || '.webp';
const cleanPath = originalUrl.split("?")[0];
const ext = path.extname(cleanPath) || ".webp";
const name = path.basename(cleanPath, ext);
const width = urlParams.get('w') || 'auto';
const width = urlParams.get("w") || "auto";
result = `_next/optimized/${name}-${width}${ext}`;
}
} catch (e) {}
} catch (e) {
// Ignore invalid optimized image URLs
}
}
// CRITICAL MAC FIX: Replace .app with -app in all paths to prevent hidden Application Bundles
// We split by / to ensure we only replace .app at the end of a directory name or filename
result = result.split('/').map((segment: string) =>
segment.endsWith('.app') ? segment.replace(/\.app$/, '-app') : segment
).join('/');
result = result
.split("/")
.map((segment: string) =>
segment.endsWith(".app")
? segment.replace(/\.app$/, "-app")
: segment,
)
.join("/");
return { filename: result };
});
@@ -59,19 +69,23 @@ class PortfolioPlugin {
async function cloneWebsite() {
const url = process.argv[2];
if (!url) {
console.error('Please provide a URL as an argument.');
console.error("Please provide a URL as an argument.");
process.exit(1);
}
const domain = new URL(url).hostname;
let outputDirName = process.argv[3] || domain.replace(/\./g, '-');
let outputDirName = process.argv[3] || domain.replace(/\./g, "-");
// Sanitize top-level folder name for Mac
if (outputDirName.endsWith('.app')) {
outputDirName = outputDirName.replace(/\.app$/, '-app');
if (outputDirName.endsWith(".app")) {
outputDirName = outputDirName.replace(/\.app$/, "-app");
}
const outputDir = path.resolve(__dirname, '../cloned-websites', outputDirName);
const outputDir = path.resolve(
__dirname,
"../cloned-websites",
outputDirName,
);
if (fs.existsSync(outputDir)) {
fs.rmSync(outputDir, { recursive: true, force: true });
@@ -88,61 +102,84 @@ async function cloneWebsite() {
requestConcurrency: 10,
plugins: [
new PuppeteerPlugin({
launchOptions: { headless: true, args: ['--no-sandbox'] },
gotoOptions: { waitUntil: 'networkidle0', timeout: 60000 },
scrollToBottom: { timeout: 20000, viewportN: 20 },
launchOptions: { headless: true, args: ["--no-sandbox"] },
gotoOptions: { waitUntil: "networkidle0", timeout: 60000 },
scrollToBottom: { timeout: 20000, viewportN: 20 },
}),
new PortfolioPlugin()
new PortfolioPlugin(),
],
sources: [
{ selector: 'img', attr: 'src' },
{ selector: 'img', attr: 'srcset' },
{ selector: 'img', attr: 'data-src' },
{ selector: 'img', attr: 'data-srcset' },
{ selector: 'link[rel="stylesheet"]', attr: 'href' },
{ selector: 'link[rel*="icon"]', attr: 'href' },
{ selector: 'script', attr: 'src' },
{ selector: 'link[rel="preload"]', attr: 'href' },
{ selector: 'link[rel="prefetch"]', attr: 'href' },
{ selector: 'link[rel="modulepreload"]', attr: 'href' },
{ selector: 'link[rel="apple-touch-icon"]', attr: 'href' },
{ selector: 'link[rel="mask-icon"]', attr: 'href' },
{ selector: 'source', attr: 'src' },
{ selector: 'source', attr: 'srcset' },
{ selector: 'video', attr: 'src' },
{ selector: 'video', attr: 'poster' },
{ selector: 'audio', attr: 'src' },
{ selector: 'iframe', attr: 'src' },
{ selector: 'meta[property="og:image"]', attr: 'content' },
{ selector: 'meta[name="twitter:image"]', attr: 'content' },
{ selector: '[style]', attr: 'style' },
{ selector: "img", attr: "src" },
{ selector: "img", attr: "srcset" },
{ selector: "img", attr: "data-src" },
{ selector: "img", attr: "data-srcset" },
{ selector: 'link[rel="stylesheet"]', attr: "href" },
{ selector: 'link[rel*="icon"]', attr: "href" },
{ selector: "script", attr: "src" },
{ selector: 'link[rel="preload"]', attr: "href" },
{ selector: 'link[rel="prefetch"]', attr: "href" },
{ selector: 'link[rel="modulepreload"]', attr: "href" },
{ selector: 'link[rel="apple-touch-icon"]', attr: "href" },
{ selector: 'link[rel="mask-icon"]', attr: "href" },
{ selector: "source", attr: "src" },
{ selector: "source", attr: "srcset" },
{ selector: "video", attr: "src" },
{ selector: "video", attr: "poster" },
{ selector: "audio", attr: "src" },
{ selector: "iframe", attr: "src" },
{ selector: 'meta[property="og:image"]', attr: "content" },
{ selector: 'meta[name="twitter:image"]', attr: "content" },
{ selector: "[style]", attr: "style" },
],
urlFilter: (link: string) => {
const isAsset = /\.(js|css|jpg|jpeg|png|gif|svg|webp|woff|woff2|ttf|eot|otf|mp4|webm|mov|ogg|pdf|ico)(\?.*)?$/i.test(link);
const isNextAsset = link.includes('/_next/');
const isSameDomain = link.startsWith(url) || link.startsWith('/') || !link.includes('://') || link.includes(domain);
const isGoogleTagManager = link.includes('googletagmanager.com');
const isAnalytics = link.includes('analytics.mintel.me');
const isVercelApp = link.includes('vercel.app');
const isDataUrl = link.startsWith('data:');
const isMailto = link.startsWith('mailto:');
const isTel = link.startsWith('tel:');
return (isAsset || isNextAsset || isSameDomain || isGoogleTagManager || isAnalytics || isVercelApp) && !isDataUrl && !isMailto && !isTel;
const isAsset =
/\.(js|css|jpg|jpeg|png|gif|svg|webp|woff|woff2|ttf|eot|otf|mp4|webm|mov|ogg|pdf|ico)(\?.*)?$/i.test(
link,
);
const isNextAsset = link.includes("/_next/");
const isSameDomain =
link.startsWith(url) ||
link.startsWith("/") ||
!link.includes("://") ||
link.includes(domain);
const isGoogleTagManager = link.includes("googletagmanager.com");
const isAnalytics = link.includes("analytics.mintel.me");
const isVercelApp = link.includes("vercel.app");
const isDataUrl = link.startsWith("data:");
const isMailto = link.startsWith("mailto:");
const isTel = link.startsWith("tel:");
return (
(isAsset ||
isNextAsset ||
isSameDomain ||
isGoogleTagManager ||
isAnalytics ||
isVercelApp) &&
!isDataUrl &&
!isMailto &&
!isTel
);
},
filenameGenerator: 'bySiteStructure',
filenameGenerator: "bySiteStructure",
subdirectories: [
{ directory: 'img', extensions: ['.jpg', '.png', '.svg', '.webp', '.gif', '.ico'] },
{ directory: 'js', extensions: ['.js'] },
{ directory: 'css', extensions: ['.css'] },
{ directory: 'fonts', extensions: ['.woff', '.woff2', '.ttf', '.eot', '.otf'] },
{ directory: 'videos', extensions: ['.mp4', '.webm', '.mov', '.ogg'] },
{
directory: "img",
extensions: [".jpg", ".png", ".svg", ".webp", ".gif", ".ico"],
},
{ directory: "js", extensions: [".js"] },
{ directory: "css", extensions: [".css"] },
{
directory: "fonts",
extensions: [".woff", ".woff2", ".ttf", ".eot", ".otf"],
},
{ directory: "videos", extensions: [".mp4", ".webm", ".mov", ".ogg"] },
],
});
console.log('✅ Website cloned successfully!');
console.log("✅ Website cloned successfully!");
console.log(`Location: ${outputDir}`);
} catch (error) {
console.error('❌ Error cloning website:', error);
console.error("❌ Error cloning website:", error);
process.exit(1);
}
}

View File

@@ -4,10 +4,7 @@ import * as readline from "node:readline/promises";
import { fileURLToPath } from "node:url";
import { createElement } from "react";
import { renderToFile } from "@react-pdf/renderer";
import {
calculatePositions,
calculateTotals,
} from "../src/logic/pricing/calculator.js";
import { calculateTotals } from "../src/logic/pricing/calculator.js";
import { CombinedQuotePDF } from "../src/components/CombinedQuotePDF.js";
import { initialState, PRICING } from "../src/logic/pricing/constants.js";
import {
@@ -18,7 +15,6 @@ import {
} from "../src/logic/content-provider.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function main() {
const args = process.argv.slice(2);

View File

@@ -1,8 +1,8 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import axios from "axios";
import * as cheerio from "cheerio";
import { execSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
/**
* PageSpeed Test Script
@@ -13,10 +13,15 @@ import * as path from 'path';
*/
const targetUrl =
process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'https://testing.mintel.me';
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20;
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'mintel';
const gatekeeperCookie = process.env.AUTH_COOKIE_NAME || 'mintel_gatekeeper_session';
process.argv[2] ||
process.env.NEXT_PUBLIC_BASE_URL ||
"https://testing.mintel.me";
const limit = process.env.PAGESPEED_LIMIT
? parseInt(process.env.PAGESPEED_LIMIT)
: 20;
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || "mintel";
const gatekeeperCookie =
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
async function main() {
console.log(`\n🚀 Starting PageSpeed test for: ${targetUrl}`);
@@ -24,7 +29,7 @@ async function main() {
try {
// 1. Fetch Sitemap
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
const sitemapUrl = `${targetUrl.replace(/\/$/, "")}/sitemap.xml`;
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
// We might need to bypass gatekeeper for the sitemap fetch too
@@ -36,21 +41,21 @@ async function main() {
});
const $ = cheerio.load(response.data, { xmlMode: true });
let urls = $('url loc')
.map((i, el) => $(el).text())
let urls = $("url loc")
.map((_i, el) => $(el).text())
.get();
// Cleanup, filter and normalize domains to targetUrl
const urlPattern = /https?:\/\/[^\/]+/;
urls = [...new Set(urls)]
.filter((u) => u.startsWith('http'))
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
.filter((u) => u.startsWith("http"))
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, "")))
.sort();
console.log(`✅ Found ${urls.length} URLs in sitemap.`);
if (urls.length === 0) {
console.error('❌ No URLs found in sitemap. Is the site up?');
console.error("❌ No URLs found in sitemap. Is the site up?");
process.exit(1);
}
@@ -59,7 +64,9 @@ async function main() {
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
);
// Try to pick a variety: home, some products, some blog posts
const home = urls.filter((u) => u.endsWith('/de') || u.endsWith('/en') || u === targetUrl);
const home = urls.filter(
(u) => u.endsWith("/de") || u.endsWith("/en") || u === targetUrl,
);
const others = urls.filter((u) => !home.includes(u));
urls = [...home, ...others.slice(0, limit - home.length)];
}
@@ -69,7 +76,7 @@ async function main() {
// 2. Prepare LHCI command
// We use --collect.url multiple times
const urlArgs = urls.map((u) => `--collect.url="${u}"`).join(' ');
const urlArgs = urls.map((u) => `--collect.url="${u}"`).join(" ");
// Handle authentication for staging/testing
// Lighthouse can set cookies via --collect.settings.extraHeaders
@@ -77,12 +84,15 @@ async function main() {
Cookie: `${gatekeeperCookie}=${gatekeeperPassword}`,
});
const chromePath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
const chromePathArg = chromePath ? `--collect.chromePath="${chromePath}"` : '';
const chromePath =
process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
const chromePathArg = chromePath
? `--collect.chromePath="${chromePath}"`
: "";
// Clean up old reports
if (fs.existsSync('.lighthouseci')) {
fs.rmSync('.lighthouseci', { recursive: true, force: true });
if (fs.existsSync(".lighthouseci")) {
fs.rmSync(".lighthouseci", { recursive: true, force: true });
}
// Using a more robust way to execute and capture output
@@ -93,27 +103,31 @@ async function main() {
try {
execSync(lhciCommand, {
encoding: 'utf8',
stdio: 'inherit',
encoding: "utf8",
stdio: "inherit",
});
} catch (err: any) {
console.warn('⚠️ LHCI assertion finished with warnings or errors.');
console.warn("⚠️ LHCI assertion finished with warnings or errors.");
// We continue to show the table even if assertions failed
}
// 3. Summarize Results (Local & Independent)
const manifestPath = path.join(process.cwd(), '.lighthouseci', 'manifest.json');
const manifestPath = path.join(
process.cwd(),
".lighthouseci",
"manifest.json",
);
if (fs.existsSync(manifestPath)) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
console.log(`\n📊 PageSpeed Summary (FOSS - Local Report):\n`);
const summaryTable = manifest.map((entry: any) => {
const s = entry.summary;
return {
URL: entry.url.replace(targetUrl, ''),
URL: entry.url.replace(targetUrl, ""),
Perf: Math.round(s.performance * 100),
Acc: Math.round(s.accessibility * 100),
BP: Math.round(s['best-practices'] * 100),
BP: Math.round(s["best-practices"] * 100),
SEO: Math.round(s.seo * 100),
};
});
@@ -123,24 +137,30 @@ async function main() {
// Calculate Average
const avg = {
Perf: Math.round(
summaryTable.reduce((acc: any, curr: any) => acc + curr.Perf, 0) / summaryTable.length,
summaryTable.reduce((acc: any, curr: any) => acc + curr.Perf, 0) /
summaryTable.length,
),
Acc: Math.round(
summaryTable.reduce((acc: any, curr: any) => acc + curr.Acc, 0) / summaryTable.length,
summaryTable.reduce((acc: any, curr: any) => acc + curr.Acc, 0) /
summaryTable.length,
),
BP: Math.round(
summaryTable.reduce((acc: any, curr: any) => acc + curr.BP, 0) / summaryTable.length,
summaryTable.reduce((acc: any, curr: any) => acc + curr.BP, 0) /
summaryTable.length,
),
SEO: Math.round(
summaryTable.reduce((acc: any, curr: any) => acc + curr.SEO, 0) / summaryTable.length,
summaryTable.reduce((acc: any, curr: any) => acc + curr.SEO, 0) /
summaryTable.length,
),
};
console.log(`\n📈 Average Scores:`);
console.log(` Performance: ${avg.Perf > 90 ? '✅' : '⚠️'} ${avg.Perf}`);
console.log(` Accessibility: ${avg.Acc > 90 ? '✅' : '⚠️'} ${avg.Acc}`);
console.log(` Best Practices: ${avg.BP > 90 ? '✅' : '⚠️'} ${avg.BP}`);
console.log(` SEO: ${avg.SEO > 90 ? '✅' : '⚠️'} ${avg.SEO}`);
console.log(
` Performance: ${avg.Perf > 90 ? "✅" : "⚠️"} ${avg.Perf}`,
);
console.log(` Accessibility: ${avg.Acc > 90 ? "✅" : "⚠️"} ${avg.Acc}`);
console.log(` Best Practices: ${avg.BP > 90 ? "✅" : "⚠️"} ${avg.BP}`);
console.log(` SEO: ${avg.SEO > 90 ? "✅" : "⚠️"} ${avg.SEO}`);
}
console.log(`\n✨ PageSpeed tests completed successfully!`);

View File

@@ -3,83 +3,90 @@
* Verify components can be imported and used
*/
import { join } from 'path';
import { join } from "path";
console.log('🔍 Verifying Embed Components...\n');
console.log("🔍 Verifying Embed Components...\n");
// Test 1: Check if components can be imported
try {
const YouTubePath = join(process.cwd(), 'src', 'components', 'YouTubeEmbed.astro');
const TwitterPath = join(process.cwd(), 'src', 'components', 'TwitterEmbed.astro');
const GenericPath = join(process.cwd(), 'src', 'components', 'GenericEmbed.astro');
console.log('✅ YouTubeEmbed.astro exists');
console.log('✅ TwitterEmbed.astro exists');
console.log('✅ GenericEmbed.astro exists');
console.log("✅ YouTubeEmbed.astro exists");
console.log("✅ TwitterEmbed.astro exists");
console.log("✅ GenericEmbed.astro exists");
} catch (error) {
console.log('❌ Component import error:', error);
console.log("❌ Component import error:", error);
}
// Test 2: Check demo post accessibility
try {
const demoPath = join(process.cwd(), 'src', 'pages', 'blog', 'embed-demo.astro');
const { readFileSync } = require('fs');
if (require('fs').existsSync(demoPath)) {
const content = readFileSync(demoPath, 'utf-8');
const demoPath = join(
process.cwd(),
"src",
"pages",
"blog",
"embed-demo.astro",
);
const { readFileSync } = require("fs");
if (require("fs").existsSync(demoPath)) {
const content = readFileSync(demoPath, "utf-8");
// Check if demo has proper structure
const hasImports = content.includes('import YouTubeEmbed') &&
content.includes('import TwitterEmbed') &&
content.includes('import GenericEmbed');
const hasUsage = content.includes('<YouTubeEmbed') &&
content.includes('<TwitterEmbed') &&
content.includes('<GenericEmbed>');
const hasImports =
content.includes("import YouTubeEmbed") &&
content.includes("import TwitterEmbed") &&
content.includes("import GenericEmbed");
const hasUsage =
content.includes("<YouTubeEmbed") &&
content.includes("<TwitterEmbed") &&
content.includes("<GenericEmbed>");
if (hasImports && hasUsage) {
console.log('✅ Demo post has correct imports and usage');
console.log("✅ Demo post has correct imports and usage");
} else {
console.log('❌ Demo post missing imports or usage');
console.log("❌ Demo post missing imports or usage");
}
// Check if it has BaseLayout
if (content.includes('BaseLayout')) {
console.log('✅ Demo post uses BaseLayout');
if (content.includes("BaseLayout")) {
console.log("✅ Demo post uses BaseLayout");
} else {
console.log('❌ Demo post missing BaseLayout');
console.log("❌ Demo post missing BaseLayout");
}
}
} catch (error) {
console.log('❌ Demo post check error:', error);
console.log("❌ Demo post check error:", error);
}
// Test 3: Check blogPosts array
try {
const blogPostsPath = join(process.cwd(), 'src', 'data', 'blogPosts.ts');
const { readFileSync } = require('fs');
const content = readFileSync(blogPostsPath, 'utf-8');
const blogPostsPath = join(process.cwd(), "src", "data", "blogPosts.ts");
const { readFileSync } = require("fs");
const content = readFileSync(blogPostsPath, "utf-8");
// Check if embed-demo needs to be added
if (!content.includes('embed-demo')) {
console.log('⚠️ embed-demo not in blogPosts array - this is why it won\'t show in blog list');
console.log(' But it should still be accessible at /blog/embed-demo directly');
if (!content.includes("embed-demo")) {
console.log(
"⚠️ embed-demo not in blogPosts array - this is why it won't show in blog list",
);
console.log(
" But it should still be accessible at /blog/embed-demo directly",
);
} else {
console.log('✅ embed-demo found in blogPosts array');
console.log("✅ embed-demo found in blogPosts array");
}
} catch (error) {
console.log('❌ blogPosts check error:', error);
console.log("❌ blogPosts check error:", error);
}
console.log('\n' + '='.repeat(60));
console.log('📋 SUMMARY:');
console.log('• Components are created and structured correctly');
console.log('• Demo post exists at src/pages/blog/embed-demo.astro');
console.log('• Demo post has all required imports and usage');
console.log('\n🔧 TO FIX BLOG LISTING:');
console.log('Add embed-demo to src/data/blogPosts.ts array');
console.log('\n🚀 TO TEST COMPONENTS:');
console.log('Visit: http://localhost:4321/blog/embed-demo');
console.log('If that 404s, the demo post needs to be added to blogPosts.ts');
console.log("\n" + "=".repeat(60));
console.log("📋 SUMMARY:");
console.log("• Components are created and structured correctly");
console.log("• Demo post exists at src/pages/blog/embed-demo.astro");
console.log("• Demo post has all required imports and usage");
console.log("\n🔧 TO FIX BLOG LISTING:");
console.log("Add embed-demo to src/data/blogPosts.ts array");
console.log("\n🚀 TO TEST COMPONENTS:");
console.log("Visit: http://localhost:4321/blog/embed-demo");
console.log("If that 404s, the demo post needs to be added to blogPosts.ts");

View File

@@ -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>
);
};

View File

@@ -67,7 +67,6 @@ export const CombinedQuotePDF = ({
{showAgbs && (
<AgbsPDF
mode={mode}
state={estimationProps.state}
headerIcon={estimationProps.headerIcon}
footerLogo={estimationProps.footerLogo}
/>

View File

@@ -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}
/>

View File

@@ -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 {

View File

@@ -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],
}));

View File

@@ -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),
});
}
}

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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" }}

View File

@@ -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>
);

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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>
);

View File

@@ -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>
))}

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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 && (

View File

@@ -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 },

View File

@@ -1,12 +1,14 @@
import fs from "fs";
import path from "path";
/* eslint-disable no-unused-vars */
const DOCS_DIR = path.join(process.cwd(), "docs");
export function getTechDetails() {
try {
const content = fs.readFileSync(path.join(DOCS_DIR, "TECH.md"), "utf-8");
const sections = content.split("⸻").map((s) => s.trim());
const _content = fs.readFileSync(path.join(DOCS_DIR, "TECH.md"), "utf-8");
const _sections = _content.split("⸻").map((s) => s.trim());
// Extract items (Speed, Responsive, Stability, etc.)
// Logic: Look for section headers and their summaries
@@ -46,7 +48,7 @@ export function getTechDetails() {
export function getPrinciples() {
try {
const content = fs.readFileSync(
const _content = fs.readFileSync(
path.join(DOCS_DIR, "PRINCIPLES.md"),
"utf-8",
);
@@ -86,7 +88,7 @@ export function getPrinciples() {
export function getMaintenanceDetails() {
try {
const content = fs.readFileSync(
const _content = fs.readFileSync(
path.join(DOCS_DIR, "MAINTENANCE.md"),
"utf-8",
);
@@ -122,7 +124,7 @@ export function getMaintenanceDetails() {
export function getStandardsDetails() {
try {
const content = fs.readFileSync(
const _content = fs.readFileSync(
path.join(DOCS_DIR, "STANDARDS.md"),
"utf-8",
);

View File

@@ -2,6 +2,8 @@
* Analytics interfaces - decoupled contracts
*/
/* eslint-disable no-unused-vars */
export interface AnalyticsEvent {
name: string;
props?: Record<string, any>;
@@ -9,12 +11,12 @@ export interface AnalyticsEvent {
export interface AnalyticsAdapter {
track(event: AnalyticsEvent): Promise<void>;
identify?(userId: string, traits?: Record<string, any>): Promise<void>;
page?(path: string, props?: Record<string, any>): Promise<void>;
identify?(_userId: string, _traits?: Record<string, any>): Promise<void>;
page?(_path: string, _props?: Record<string, any>): Promise<void>;
getScriptTag?(): string;
}
export interface AnalyticsConfig {
domain?: string;
scriptUrl?: string;
}
}

View File

@@ -1,96 +1,101 @@
import type { CacheAdapter, CacheConfig } from './interfaces';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { existsSync, mkdirSync } from 'node:fs';
import * as crypto from 'node:crypto';
import type { CacheAdapter, CacheConfig } from "./interfaces";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { existsSync } from "node:fs";
import * as crypto from "node:crypto";
/* eslint-disable no-unused-vars */
export class FileCacheAdapter implements CacheAdapter {
private cacheDir: string;
private prefix: string;
private defaultTTL: number;
private cacheDir: string;
private prefix: string;
private defaultTTL: number;
constructor(config?: CacheConfig & { cacheDir?: string }) {
this.cacheDir = config?.cacheDir || path.resolve(process.cwd(), '.cache');
this.prefix = config?.prefix || '';
this.defaultTTL = config?.defaultTTL || 3600;
constructor(config?: CacheConfig & { cacheDir?: string }) {
this.cacheDir = config?.cacheDir || path.resolve(process.cwd(), ".cache");
this.prefix = config?.prefix || "";
this.defaultTTL = config?.defaultTTL || 3600;
if (!existsSync(this.cacheDir)) {
fs.mkdir(this.cacheDir, { recursive: true }).catch(err => {
console.error(`Failed to create cache directory: ${this.cacheDir}`, err);
});
}
}
private sanitize(key: string): string {
const clean = key.replace(/[^a-z0-9]/gi, '_');
if (clean.length > 64) {
return crypto.createHash('md5').update(key).digest('hex');
}
return clean;
}
private getFilePath(key: string): string {
const safeKey = this.sanitize(`${this.prefix}${key}`).toLowerCase();
return path.join(this.cacheDir, `${safeKey}.json`);
}
async get<T>(key: string): Promise<T | null> {
const filePath = this.getFilePath(key);
try {
if (!existsSync(filePath)) return null;
const content = await fs.readFile(filePath, 'utf8');
const data = JSON.parse(content);
if (data.expiry && Date.now() > data.expiry) {
await this.del(key);
return null;
}
return data.value;
} catch (error) {
return null;
}
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
const filePath = this.getFilePath(key);
const effectiveTTL = ttl !== undefined ? ttl : this.defaultTTL;
const data = {
value,
expiry: effectiveTTL > 0 ? Date.now() + effectiveTTL * 1000 : null,
updatedAt: new Date().toISOString()
};
try {
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
} catch (error) {
console.error(`Failed to write cache file: ${filePath}`, error);
}
}
async del(key: string): Promise<void> {
const filePath = this.getFilePath(key);
try {
if (existsSync(filePath)) {
await fs.unlink(filePath);
}
} catch (error) {
// Ignore
}
}
async clear(): Promise<void> {
try {
if (existsSync(this.cacheDir)) {
const files = await fs.readdir(this.cacheDir);
for (const file of files) {
if (file.endsWith('.json')) {
await fs.unlink(path.join(this.cacheDir, file));
}
}
}
} catch (error) {
// Ignore
if (!existsSync(this.cacheDir)) {
fs.mkdir(this.cacheDir, { recursive: true }).catch((err) => {
console.error(
`Failed to create cache directory: ${this.cacheDir}`,
err,
);
});
}
}
private sanitize(key: string): string {
const clean = key.replace(/[^a-z0-9]/gi, "_");
if (clean.length > 64) {
return crypto.createHash("md5").update(key).digest("hex");
}
return clean;
}
private getFilePath(key: string): string {
const safeKey = this.sanitize(`${this.prefix}${key}`).toLowerCase();
return path.join(this.cacheDir, `${safeKey}.json`);
}
async get<T>(key: string): Promise<T | null> {
const filePath = this.getFilePath(key);
try {
if (!existsSync(filePath)) return null;
const content = await fs.readFile(filePath, "utf8");
const data = JSON.parse(content);
if (data.expiry && Date.now() > data.expiry) {
await this.del(key);
return null;
}
return data.value;
} catch (_error) {
return null;
}
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
const filePath = this.getFilePath(key);
const effectiveTTL = ttl !== undefined ? ttl : this.defaultTTL;
const data = {
value,
expiry: effectiveTTL > 0 ? Date.now() + effectiveTTL * 1000 : null,
updatedAt: new Date().toISOString(),
};
try {
await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
} catch (error) {
console.error(`Failed to write cache file: ${filePath}`, error);
}
}
async del(key: string): Promise<void> {
const filePath = this.getFilePath(key);
try {
if (existsSync(filePath)) {
await fs.unlink(filePath);
}
} catch (_error) {
// Ignore
}
}
async clear(): Promise<void> {
try {
if (existsSync(this.cacheDir)) {
const files = await fs.readdir(this.cacheDir);
for (const file of files) {
if (file.endsWith(".json")) {
await fs.unlink(path.join(this.cacheDir, file));
}
}
}
} catch (_error) {
// Ignore
}
}
}

View File

@@ -3,15 +3,17 @@
* This simulates what happens when a blog post is rendered
*/
import { blogPosts } from '../data/blogPosts';
import { FileExampleManager } from '../data/fileExamples';
import { blogPosts } from "../data/blogPosts";
import { FileExampleManager } from "../data/fileExamples";
/* eslint-disable no-unused-vars */
export async function testBlogPostIntegration() {
console.log('🧪 Testing Blog Post + File Examples Integration...\n');
console.log("🧪 Testing Blog Post + File Examples Integration...\n");
let passed = 0;
let failed = 0;
const test = (name: string, fn: () => void | Promise<void>) => {
try {
const result = fn();
@@ -38,15 +40,15 @@ export async function testBlogPostIntegration() {
};
// Test 1: Blog posts exist
test('Blog posts are loaded', () => {
test("Blog posts are loaded", () => {
if (!blogPosts || blogPosts.length === 0) {
throw new Error('No blog posts found');
throw new Error("No blog posts found");
}
console.log(` Found ${blogPosts.length} posts`);
});
// Test 2: Each post has required fields
test('All posts have required fields', () => {
test("All posts have required fields", () => {
for (const post of blogPosts) {
if (!post.slug || !post.title || !post.tags) {
throw new Error(`Post ${post.slug} missing required fields`);
@@ -55,207 +57,255 @@ export async function testBlogPostIntegration() {
});
// Test 3: Debugging-tips post should have file examples
test('debugging-tips post has file examples', async () => {
const post = blogPosts.find(p => p.slug === 'debugging-tips');
test("debugging-tips post has file examples", async () => {
const post = blogPosts.find((p) => p.slug === "debugging-tips");
if (!post) {
throw new Error('debugging-tips post not found');
throw new Error("debugging-tips post not found");
}
// Check if it would trigger file examples
const showFileExamples = post.tags?.some(tag =>
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
const _showFileExamples = post.tags?.some((tag) =>
[
"architecture",
"design-patterns",
"system-design",
"docker",
"deployment",
].includes(tag),
);
// debugging-tips has tags ['debugging', 'tools'] so showFileExamples would be false
// But it has hardcoded FileExamplesList in the template
// Verify files exist for this post
const groups = await FileExampleManager.getAllGroups();
const filesForPost = groups.flatMap(g => g.files).filter(f => f.postSlug === 'debugging-tips');
const filesForPost = groups
.flatMap((g) => g.files)
.filter((f) => f.postSlug === "debugging-tips");
if (filesForPost.length === 0) {
throw new Error('No files found for debugging-tips');
throw new Error("No files found for debugging-tips");
}
console.log(` Found ${filesForPost.length} files for debugging-tips`);
});
// Test 4: Architecture-patterns post should have file examples
test('architecture-patterns post has file examples', async () => {
const post = blogPosts.find(p => p.slug === 'architecture-patterns');
test("architecture-patterns post has file examples", async () => {
const post = blogPosts.find((p) => p.slug === "architecture-patterns");
if (!post) {
throw new Error('architecture-patterns post not found');
throw new Error("architecture-patterns post not found");
}
// Check if it would trigger file examples
const showFileExamples = post.tags?.some(tag =>
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
const showFileExamples = post.tags?.some((tag) =>
[
"architecture",
"design-patterns",
"system-design",
"docker",
"deployment",
].includes(tag),
);
if (!showFileExamples) {
throw new Error('architecture-patterns should show file examples');
throw new Error("architecture-patterns should show file examples");
}
// Verify files exist for this post
const groups = await FileExampleManager.getAllGroups();
const filesForPost = groups.flatMap(g => g.files).filter(f => f.postSlug === 'architecture-patterns');
const filesForPost = groups
.flatMap((g) => g.files)
.filter((f) => f.postSlug === "architecture-patterns");
if (filesForPost.length === 0) {
throw new Error('No files found for architecture-patterns');
throw new Error("No files found for architecture-patterns");
}
console.log(` Found ${filesForPost.length} files for architecture-patterns`);
console.log(
` Found ${filesForPost.length} files for architecture-patterns`,
);
});
// Test 5: Docker-deployment post should have file examples
test('docker-deployment post has file examples', async () => {
const post = blogPosts.find(p => p.slug === 'docker-deployment');
test("docker-deployment post has file examples", async () => {
const post = blogPosts.find((p) => p.slug === "docker-deployment");
if (!post) {
throw new Error('docker-deployment post not found');
throw new Error("docker-deployment post not found");
}
// Check if it would trigger file examples
const showFileExamples = post.tags?.some(tag =>
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
const showFileExamples = post.tags?.some((tag) =>
[
"architecture",
"design-patterns",
"system-design",
"docker",
"deployment",
].includes(tag),
);
if (!showFileExamples) {
throw new Error('docker-deployment should show file examples');
throw new Error("docker-deployment should show file examples");
}
// Verify files exist for this post
const groups = await FileExampleManager.getAllGroups();
const filesForPost = groups.flatMap(g => g.files).filter(f => f.postSlug === 'docker-deployment');
const filesForPost = groups
.flatMap((g) => g.files)
.filter((f) => f.postSlug === "docker-deployment");
if (filesForPost.length === 0) {
throw new Error('No files found for docker-deployment');
throw new Error("No files found for docker-deployment");
}
console.log(` Found ${filesForPost.length} files for docker-deployment`);
});
// Test 6: First-note post should NOT have file examples
test('first-note post has no file examples', async () => {
const post = blogPosts.find(p => p.slug === 'first-note');
test("first-note post has no file examples", async () => {
const post = blogPosts.find((p) => p.slug === "first-note");
if (!post) {
throw new Error('first-note post not found');
throw new Error("first-note post not found");
}
// Check if it would trigger file examples
const showFileExamples = post.tags?.some(tag =>
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
const showFileExamples = post.tags?.some((tag) =>
[
"architecture",
"design-patterns",
"system-design",
"docker",
"deployment",
].includes(tag),
);
if (showFileExamples) {
throw new Error('first-note should NOT show file examples');
throw new Error("first-note should NOT show file examples");
}
// Verify no files exist for this post
const groups = await FileExampleManager.getAllGroups();
const filesForPost = groups.flatMap(g => g.files).filter(f => f.postSlug === 'first-note');
const filesForPost = groups
.flatMap((g) => g.files)
.filter((f) => f.postSlug === "first-note");
if (filesForPost.length > 0) {
throw new Error('Files found for first-note, but none should exist');
throw new Error("Files found for first-note, but none should exist");
}
console.log(` Correctly has no files`);
});
// Test 7: Simulate FileExamplesList filtering for debugging-tips
test('FileExamplesList filtering works for debugging-tips', async () => {
const postSlug = 'debugging-tips';
const groupId = 'python-data-processing';
test("FileExamplesList filtering works for debugging-tips", async () => {
const postSlug = "debugging-tips";
const groupId = "python-data-processing";
const allGroups = await FileExampleManager.getAllGroups();
const loadedGroups = allGroups
.filter(g => g.groupId === groupId)
.map(g => ({
.filter((g) => g.groupId === groupId)
.map((g) => ({
...g,
files: g.files.filter(f => f.postSlug === postSlug)
files: g.files.filter((f) => f.postSlug === postSlug),
}))
.filter(g => g.files.length > 0);
.filter((g) => g.files.length > 0);
if (loadedGroups.length === 0) {
throw new Error('No groups loaded for debugging-tips with python-data-processing');
throw new Error(
"No groups loaded for debugging-tips with python-data-processing",
);
}
if (loadedGroups[0].files.length === 0) {
throw new Error('No files in the group');
throw new Error("No files in the group");
}
console.log(` Would show ${loadedGroups[0].files.length} files`);
});
// Test 8: Simulate FileExamplesList filtering for architecture-patterns
test('FileExamplesList filtering works for architecture-patterns', async () => {
const postSlug = 'architecture-patterns';
const tags = ['architecture', 'design-patterns', 'system-design'];
test("FileExamplesList filtering works for architecture-patterns", async () => {
const postSlug = "architecture-patterns";
const tags = ["architecture", "design-patterns", "system-design"];
const allGroups = await FileExampleManager.getAllGroups();
const loadedGroups = allGroups
.map(g => ({
.map((g) => ({
...g,
files: g.files.filter(f => {
files: g.files.filter((f) => {
if (f.postSlug !== postSlug) return false;
if (tags && tags.length > 0) {
return f.tags?.some(tag => tags.includes(tag));
return f.tags?.some((tag) => tags.includes(tag));
}
return true;
})
}),
}))
.filter(g => g.files.length > 0);
.filter((g) => g.files.length > 0);
if (loadedGroups.length === 0) {
throw new Error('No groups loaded for architecture-patterns');
throw new Error("No groups loaded for architecture-patterns");
}
const totalFiles = loadedGroups.reduce((sum, g) => sum + g.files.length, 0);
if (totalFiles === 0) {
throw new Error('No files found');
throw new Error("No files found");
}
console.log(` Would show ${totalFiles} files across ${loadedGroups.length} groups`);
console.log(
` Would show ${totalFiles} files across ${loadedGroups.length} groups`,
);
});
// Test 9: Verify all file examples have postSlug
test('All file examples have postSlug property', async () => {
test("All file examples have postSlug property", async () => {
const groups = await FileExampleManager.getAllGroups();
const filesWithoutPostSlug = groups.flatMap(g => g.files).filter(f => !f.postSlug);
const filesWithoutPostSlug = groups
.flatMap((g) => g.files)
.filter((f) => !f.postSlug);
if (filesWithoutPostSlug.length > 0) {
throw new Error(`${filesWithoutPostSlug.length} files missing postSlug`);
}
console.log(` All ${groups.flatMap(g => g.files).length} files have postSlug`);
console.log(
` All ${groups.flatMap((g) => g.files).length} files have postSlug`,
);
});
// Test 10: Verify postSlugs match blog post slugs
test('File example postSlugs match blog post slugs', async () => {
test("File example postSlugs match blog post slugs", async () => {
const groups = await FileExampleManager.getAllGroups();
const filePostSlugs = new Set(groups.flatMap(g => g.files).map(f => f.postSlug));
const blogPostSlugs = new Set(blogPosts.map(p => p.slug));
const filePostSlugs = new Set(
groups.flatMap((g) => g.files).map((f) => f.postSlug),
);
const blogPostSlugs = new Set(blogPosts.map((p) => p.slug));
for (const slug of filePostSlugs) {
if (slug && !blogPostSlugs.has(slug)) {
throw new Error(`File postSlug "${slug}" doesn't match any blog post`);
}
}
console.log(` All file postSlugs match blog posts`);
});
// Wait for async tests
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(
`\n📊 Integration Test Results: ${passed} passed, ${failed} failed`,
);
console.log(`\n📊 Integration Test Results: ${passed} passed, ${failed} failed`);
if (failed === 0) {
console.log('🎉 All integration tests passed!');
console.log('\n✅ The file examples system is correctly integrated with blog posts!');
console.log("🎉 All integration tests passed!");
console.log(
"\n✅ The file examples system is correctly integrated with blog posts!",
);
} else {
console.log('❌ Some integration tests failed');
console.log("❌ Some integration tests failed");
process.exit(1);
}
}
// Export for use in other test files
// Export for use in other test files

View File

@@ -2,15 +2,19 @@
* Comprehensive tests for the file examples system
*/
import { FileExampleManager, sampleFileExamples, type FileExample } from '../data/fileExamples';
import {
FileExampleManager,
sampleFileExamples,
type FileExample as _FileExample,
} from "../data/fileExamples";
// Test helper to run all tests
export async function runFileExamplesTests() {
console.log('🧪 Running File Examples System Tests...\n');
console.log("🧪 Running File Examples System Tests...\n");
let passed = 0;
let failed = 0;
const test = (name: string, fn: () => void | Promise<void>) => {
try {
const result = fn();
@@ -37,62 +41,62 @@ export async function runFileExamplesTests() {
};
// Test 1: Data structure exists
test('File examples data is loaded', () => {
test("File examples data is loaded", () => {
if (!sampleFileExamples || sampleFileExamples.length === 0) {
throw new Error('No file examples found');
throw new Error("No file examples found");
}
console.log(` Found ${sampleFileExamples.length} groups`);
});
// Test 2: FileExampleManager exists
test('FileExampleManager class is available', () => {
test("FileExampleManager class is available", () => {
if (!FileExampleManager) {
throw new Error('FileExampleManager not found');
throw new Error("FileExampleManager not found");
}
});
// Test 3: Sample data has correct structure
test('Sample data has correct structure', () => {
test("Sample data has correct structure", () => {
const group = sampleFileExamples[0];
if (!group.groupId || !group.title || !Array.isArray(group.files)) {
throw new Error('Invalid group structure');
throw new Error("Invalid group structure");
}
const file = group.files[0];
if (!file.id || !file.filename || !file.content || !file.language) {
throw new Error('Invalid file structure');
throw new Error("Invalid file structure");
}
// Check for postSlug
if (!file.postSlug) {
throw new Error('Files missing postSlug property');
throw new Error("Files missing postSlug property");
}
});
// Test 4: Get all groups
test('FileExampleManager.getAllGroups() works', async () => {
test("FileExampleManager.getAllGroups() works", async () => {
const groups = await FileExampleManager.getAllGroups();
if (!Array.isArray(groups) || groups.length === 0) {
throw new Error('getAllGroups returned invalid result');
throw new Error("getAllGroups returned invalid result");
}
});
// Test 5: Get specific group
test('FileExampleManager.getGroup() works', async () => {
const group = await FileExampleManager.getGroup('python-data-processing');
test("FileExampleManager.getGroup() works", async () => {
const group = await FileExampleManager.getGroup("python-data-processing");
if (!group) {
throw new Error('Group not found');
throw new Error("Group not found");
}
if (group.groupId !== 'python-data-processing') {
throw new Error('Wrong group returned');
if (group.groupId !== "python-data-processing") {
throw new Error("Wrong group returned");
}
});
// Test 6: Search files
test('FileExampleManager.searchFiles() works', async () => {
const results = await FileExampleManager.searchFiles('python');
test("FileExampleManager.searchFiles() works", async () => {
const results = await FileExampleManager.searchFiles("python");
if (!Array.isArray(results)) {
throw new Error('searchFiles returned invalid result');
throw new Error("searchFiles returned invalid result");
}
if (results.length === 0) {
throw new Error('No results found for "python"');
@@ -100,165 +104,180 @@ export async function runFileExamplesTests() {
});
// Test 7: Get file by ID
test('FileExampleManager.getFileExample() works', async () => {
const file = await FileExampleManager.getFileExample('python-data-processor');
test("FileExampleManager.getFileExample() works", async () => {
const file = await FileExampleManager.getFileExample(
"python-data-processor",
);
if (!file) {
throw new Error('File not found');
throw new Error("File not found");
}
if (file.id !== 'python-data-processor') {
throw new Error('Wrong file returned');
if (file.id !== "python-data-processor") {
throw new Error("Wrong file returned");
}
});
// Test 8: Filter by postSlug
test('Filter files by postSlug', async () => {
test("Filter files by postSlug", async () => {
const groups = await FileExampleManager.getAllGroups();
const debuggingFiles = groups.flatMap(g => g.files).filter(f => f.postSlug === 'debugging-tips');
const debuggingFiles = groups
.flatMap((g) => g.files)
.filter((f) => f.postSlug === "debugging-tips");
if (debuggingFiles.length === 0) {
throw new Error('No files found for debugging-tips');
throw new Error("No files found for debugging-tips");
}
console.log(` Found ${debuggingFiles.length} files for debugging-tips`);
});
// Test 9: Filter by postSlug and groupId
test('Filter files by postSlug and groupId', async () => {
test("Filter files by postSlug and groupId", async () => {
const groups = await FileExampleManager.getAllGroups();
const filtered = groups
.filter(g => g.groupId === 'python-data-processing')
.map(g => ({
.filter((g) => g.groupId === "python-data-processing")
.map((g) => ({
...g,
files: g.files.filter(f => f.postSlug === 'debugging-tips')
files: g.files.filter((f) => f.postSlug === "debugging-tips"),
}))
.filter(g => g.files.length > 0);
.filter((g) => g.files.length > 0);
if (filtered.length === 0) {
throw new Error('No files found for debugging-tips in python-data-processing');
throw new Error(
"No files found for debugging-tips in python-data-processing",
);
}
console.log(` Found ${filtered[0].files.length} files`);
});
// Test 10: Filter by postSlug and tags
test('Filter files by postSlug and tags', async () => {
test("Filter files by postSlug and tags", async () => {
const groups = await FileExampleManager.getAllGroups();
const tags = ['architecture', 'design-patterns'];
const tags = ["architecture", "design-patterns"];
const filtered = groups
.map(g => ({
.map((g) => ({
...g,
files: g.files.filter(f =>
f.postSlug === 'architecture-patterns' &&
f.tags?.some(tag => tags.includes(tag))
)
files: g.files.filter(
(f) =>
f.postSlug === "architecture-patterns" &&
f.tags?.some((tag) => tags.includes(tag)),
),
}))
.filter(g => g.files.length > 0);
.filter((g) => g.files.length > 0);
if (filtered.length === 0) {
throw new Error('No files found for architecture-patterns with tags');
throw new Error("No files found for architecture-patterns with tags");
}
console.log(` Found ${filtered[0].files.length} files`);
});
// Test 11: Download single file
test('Download single file', async () => {
const result = await FileExampleManager.downloadFile('python-data-processor');
test("Download single file", async () => {
const result = await FileExampleManager.downloadFile(
"python-data-processor",
);
if (!result) {
throw new Error('Download failed');
throw new Error("Download failed");
}
if (!result.filename || !result.content || !result.mimeType) {
throw new Error('Invalid download result');
throw new Error("Invalid download result");
}
});
// Test 12: Download multiple files
test('Download multiple files', async () => {
const files = await FileExampleManager.downloadMultiple(['python-data-processor', 'python-config-example']);
test("Download multiple files", async () => {
const files = await FileExampleManager.downloadMultiple([
"python-data-processor",
"python-config-example",
]);
if (!Array.isArray(files) || files.length !== 2) {
throw new Error('Invalid multiple download result');
throw new Error("Invalid multiple download result");
}
});
// Test 13: Get available tags
test('Get available tags', async () => {
test("Get available tags", async () => {
const tags = await FileExampleManager.getAvailableTags();
if (!Array.isArray(tags) || tags.length === 0) {
throw new Error('No tags found');
throw new Error("No tags found");
}
if (!tags.includes('python') || !tags.includes('architecture')) {
throw new Error('Expected tags not found');
if (!tags.includes("python") || !tags.includes("architecture")) {
throw new Error("Expected tags not found");
}
});
// Test 14: Create new file example
test('Create new file example', async () => {
test("Create new file example", async () => {
const newExample = await FileExampleManager.createFileExample({
filename: 'test.py',
filename: "test.py",
content: 'print("test")',
language: 'python',
description: 'Test file',
tags: ['test'],
postSlug: 'test-post'
language: "python",
description: "Test file",
tags: ["test"],
postSlug: "test-post",
});
if (!newExample.id) {
throw new Error('New example has no ID');
throw new Error("New example has no ID");
}
// Verify it was added
const retrieved = await FileExampleManager.getFileExample(newExample.id);
if (!retrieved || retrieved.filename !== 'test.py') {
throw new Error('New example not found');
if (!retrieved || retrieved.filename !== "test.py") {
throw new Error("New example not found");
}
});
// Test 15: Update file example
test('Update file example', async () => {
const updated = await FileExampleManager.updateFileExample('python-data-processor', {
description: 'Updated description'
});
if (!updated || updated.description !== 'Updated description') {
throw new Error('Update failed');
test("Update file example", async () => {
const updated = await FileExampleManager.updateFileExample(
"python-data-processor",
{
description: "Updated description",
},
);
if (!updated || updated.description !== "Updated description") {
throw new Error("Update failed");
}
});
// Test 16: Delete file example
test('Delete file example', async () => {
test("Delete file example", async () => {
// First create one
const created = await FileExampleManager.createFileExample({
filename: 'delete-test.py',
content: 'test',
language: 'python',
postSlug: 'test'
filename: "delete-test.py",
content: "test",
language: "python",
postSlug: "test",
});
// Then delete it
const deleted = await FileExampleManager.deleteFileExample(created.id);
if (!deleted) {
throw new Error('Delete failed');
throw new Error("Delete failed");
}
// Verify it's gone
const retrieved = await FileExampleManager.getFileExample(created.id);
if (retrieved) {
throw new Error('File still exists after deletion');
throw new Error("File still exists after deletion");
}
});
// Wait for all async tests to complete
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(`\n📊 Test Results: ${passed} passed, ${failed} failed`);
if (failed === 0) {
console.log('🎉 All tests passed!');
console.log("🎉 All tests passed!");
} else {
console.log('❌ Some tests failed');
console.log("❌ Some tests failed");
process.exit(1);
}
}
// Export for use in other test files
// Export for use in other test files

View File

@@ -1,254 +1,298 @@
import React from 'react';
import React from "react";
import {
AbsoluteFill,
interpolate,
useCurrentFrame,
useVideoConfig,
Easing,
Img,
staticFile,
spring,
random,
} from 'remotion';
import { MouseCursor } from '../components/MouseCursor';
import { Button } from '@/src/components/Button';
import { Loader2, Check, UserCheck, ShieldCheck } from 'lucide-react';
AbsoluteFill,
interpolate,
useCurrentFrame,
useVideoConfig,
Easing,
Img,
spring,
} from "remotion";
import { MouseCursor } from "../components/MouseCursor";
import { Button } from "@/src/components/Button";
import { Loader2, Check, ShieldCheck } from "lucide-react";
/* eslint-disable no-unused-vars */
// Import logo using the alias setup in remotion.config.ts
// We'll use the staticFile helper if it's in public, but these are in src/assets
// So we can try to import them directly if the bundler allows, or move them to public.
// Given Header.tsx imports them, they should be importable.
// import IconWhite from '@/src/assets/logo/Icon White Transparent.svg'; // Not used in this version
// Import black logo for light mode
import IconBlack from '@/src/assets/logo/Icon Black Transparent.svg';
import IconBlack from "@/src/assets/logo/Icon Black Transparent.svg";
const Background: React.FC<{ loadingOpacity: number }> = ({ loadingOpacity }) => {
return (
<AbsoluteFill className="bg-white">
{/* Website-Matching Grid */}
<div className="absolute inset-0">
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="lightGrid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#f1f5f9" strokeWidth="1" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#lightGrid)" />
</svg>
</div>
{/* Subtle Gradient Overlay */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(255,255,255,0.8)_100%)]" />
{/* Dynamic "Processing" Rings (Background Activity during loading) */}
<div
className="absolute inset-0 flex items-center justify-center pointer-events-none"
style={{ opacity: loadingOpacity * 0.1 }}
const Background: React.FC<{ loadingOpacity: number }> = ({
loadingOpacity,
}) => {
return (
<AbsoluteFill className="bg-white">
{/* Website-Matching Grid */}
<div className="absolute inset-0">
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern
id="lightGrid"
width="40"
height="40"
patternUnits="userSpaceOnUse"
>
<div className="w-[500px] h-[500px] border border-slate-900 rounded-full animate-[spin_10s_linear_infinite] opacity-20 border-t-transparent" />
<div className="absolute w-[400px] h-[400px] border border-slate-900 rounded-full animate-[spin_7s_linear_infinite_reverse] opacity-20 border-b-transparent" />
</div>
<path
d="M 40 0 L 0 0 0 40"
fill="none"
stroke="#f1f5f9"
strokeWidth="1"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#lightGrid)" />
</svg>
</div>
{/* STATIC Logo - Strictly no animation on the container */}
<div className="absolute top-12 left-12 z-0">
<div className="flex items-center gap-4">
<div className="w-14 h-14 flex items-center justify-center bg-white rounded-xl border border-slate-200 shadow-sm">
<Img src={IconBlack} className="w-8 h-8 opacity-90" />
</div>
<div className="flex flex-col opacity-80">
<span className="text-slate-900 font-sans font-bold text-lg tracking-tight leading-none">Mintel.me</span>
<span className="text-slate-400 font-serif italic text-xs mt-1">Component Library</span>
</div>
</div>
</div>
</AbsoluteFill>
);
{/* Subtle Gradient Overlay */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(255,255,255,0.8)_100%)]" />
{/* Dynamic "Processing" Rings (Background Activity during loading) */}
<div
className="absolute inset-0 flex items-center justify-center pointer-events-none"
style={{ opacity: loadingOpacity * 0.1 }}
>
<div className="w-[500px] h-[500px] border border-slate-900 rounded-full animate-[spin_10s_linear_infinite] opacity-20 border-t-transparent" />
<div className="absolute w-[400px] h-[400px] border border-slate-900 rounded-full animate-[spin_7s_linear_infinite_reverse] opacity-20 border-b-transparent" />
</div>
{/* STATIC Logo - Strictly no animation on the container */}
<div className="absolute top-12 left-12 z-0">
<div className="flex items-center gap-4">
<div className="w-14 h-14 flex items-center justify-center bg-white rounded-xl border border-slate-200 shadow-sm">
<Img src={IconBlack} className="w-8 h-8 opacity-90" />
</div>
<div className="flex flex-col opacity-80">
<span className="text-slate-900 font-sans font-bold text-lg tracking-tight leading-none">
Mintel.me
</span>
<span className="text-slate-400 font-serif italic text-xs mt-1">
Component Library
</span>
</div>
</div>
</div>
</AbsoluteFill>
);
};
// Toast Notification Component
const Toast: React.FC<{ show: boolean; text: string }> = ({ show, text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const Toast: React.FC<{ _show: boolean; _text: string }> = ({
_show,
_text,
}) => {
const _frame = useCurrentFrame();
const _fps = useVideoConfig();
// Animate in/out based on 'show' prop would require state tracking or precise frame logic
// We'll trust the parent to mount/unmount or pass an animatable value
// For video, deterministic frame-based spring is best.
// We'll actually control position purely by parent for simplicity in this demo context
return (
<div className="flex items-center gap-3 bg-slate-900 text-white px-6 py-4 rounded-xl shadow-2xl border border-slate-800">
<div className="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center text-green-400">
<ShieldCheck size={18} />
</div>
<div className="flex flex-col">
<span className="font-bold text-sm tracking-wide">Authentication Successful</span>
<span className="text-slate-400 text-xs font-medium">Access granted to secure portal</span>
</div>
</div>
);
}
return (
<div className="flex items-center gap-3 bg-slate-900 text-white px-6 py-4 rounded-xl shadow-2xl border border-slate-800">
<div className="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center text-green-400">
<ShieldCheck size={18} />
</div>
<div className="flex flex-col">
<span className="font-bold text-sm tracking-wide">
Authentication Successful
</span>
<span className="text-slate-400 text-xs font-medium">
Access granted to secure portal
</span>
</div>
</div>
);
};
export const ButtonShowcase: React.FC = () => {
const frame = useCurrentFrame();
const { width, height, fps } = useVideoConfig();
const frame = useCurrentFrame();
const { width, height, fps } = useVideoConfig();
// ---- SEQUENCE TIMELINE (300 frames / 5s) ----
const ENTER_START = 20;
const HOVER_START = 70;
const CLICK_FRAME = 90;
const LOADING_START = 95;
const SUCCESS_START = 180;
const TOAST_START = 190;
const TOAST_END = 260;
const EXIT_START = 220;
// ---- SEQUENCE TIMELINE (300 frames / 5s) ----
const ENTER_START = 20;
const HOVER_START = 70;
const CLICK_FRAME = 90;
const LOADING_START = 95;
const SUCCESS_START = 180;
const TOAST_START = 190;
const TOAST_END = 260;
const EXIT_START = 220;
// 1. Mouse Animation (Bézier Path)
const getMousePos = (f: number) => {
const startX = width * 1.2;
const startY = height * 1.2;
const targetX = width / 2;
const targetY = height / 2;
// 1. Mouse Animation (Bézier Path)
const getMousePos = (f: number) => {
const startX = width * 1.2;
const startY = height * 1.2;
const targetX = width / 2;
const targetY = height / 2;
if (f < ENTER_START) return { x: startX, y: startY, vx: 0 };
if (f < ENTER_START) return { x: startX, y: startY, vx: 0 };
// Approach
if (f < HOVER_START) {
const t = interpolate(f, [ENTER_START, HOVER_START], [0, 1], { extrapolateRight: 'clamp' });
const ease = Easing.bezier(0.16, 1, 0.3, 1)(t);
const x = interpolate(ease, [0, 1], [startX, targetX]);
const y = interpolate(ease, [0, 1], [startY, targetY]);
return { x, y, vx: -10 }; // Approximate Velocity
}
// Hover
if (f < EXIT_START) {
const noise = Math.sin(f * 0.1) * 2;
return { x: targetX + noise, y: targetY + noise, vx: 0 };
}
// Exit
const t = interpolate(f, [EXIT_START, EXIT_START + 30], [0, 1], { extrapolateLeft: 'clamp' });
const ease = Easing.exp(t);
return {
x: interpolate(ease, [0, 1], [targetX, width * 0.9]),
y: interpolate(ease, [0, 1], [targetY, -100]),
vx: 10
};
};
const { x: mouseX, y: mouseY, vx } = getMousePos(frame);
// 3D Cursor Skew
const cursorSkew = interpolate(vx, [-20, 20], [20, -20], { extrapolateRight: 'clamp', extrapolateLeft: 'clamp' });
const isClicking = frame >= CLICK_FRAME && frame < CLICK_FRAME + 8;
const clickRotate = isClicking ? 30 : 0;
// 2. Button State Logic
const isLoading = frame >= LOADING_START && frame < SUCCESS_START;
const isSuccess = frame >= SUCCESS_START;
// Loading Spinner Rotation
const spinnerRot = interpolate(frame, [LOADING_START, SUCCESS_START], [0, 720]);
// Button Scale Physics
const pressSpring = spring({ frame: frame - CLICK_FRAME, fps, config: { stiffness: 400, damping: 20 } });
const successSpring = spring({ frame: frame - SUCCESS_START, fps, config: { stiffness: 200, damping: 15 } });
// Morph scale: Click(Compress) -> Loading(Normal) -> Success(Pop)
let buttonScale = 1;
if (frame >= CLICK_FRAME && frame < LOADING_START) {
buttonScale = 1 - (pressSpring * 0.05);
} else if (isSuccess) {
buttonScale = 1 + (successSpring * 0.05);
// Approach
if (f < HOVER_START) {
const t = interpolate(f, [ENTER_START, HOVER_START], [0, 1], {
extrapolateRight: "clamp",
});
const ease = Easing.bezier(0.16, 1, 0.3, 1)(t);
const x = interpolate(ease, [0, 1], [startX, targetX]);
const y = interpolate(ease, [0, 1], [startY, targetY]);
return { x, y, vx: -10 }; // Approximate Velocity
}
// Button Width Morph (Optional: Make it circle on load? Keeping it wide for consistency is safer)
// Hover
if (f < EXIT_START) {
const noise = Math.sin(f * 0.1) * 2;
return { x: targetX + noise, y: targetY + noise, vx: 0 };
}
// 3. Toast Animation
const toastSpring = spring({ frame: frame - TOAST_START, fps, config: { stiffness: 100, damping: 15 } });
const toastExit = spring({ frame: frame - TOAST_END, fps, config: { stiffness: 100, damping: 20 } });
const toastY = interpolate(toastSpring, [0, 1], [100, -80]) + interpolate(toastExit, [0, 1], [0, 200]);
const toastOpacity = interpolate(toastSpring, [0, 1], [0, 1]) - interpolate(toastExit, [0, 0.5], [0, 1]);
// Exit
const t = interpolate(f, [EXIT_START, EXIT_START + 30], [0, 1], {
extrapolateLeft: "clamp",
});
const ease = Easing.exp(t);
return {
x: interpolate(ease, [0, 1], [targetX, width * 0.9]),
y: interpolate(ease, [0, 1], [targetY, -100]),
vx: 10,
};
};
return (
<AbsoluteFill className="items-center justify-center overflow-hidden bg-white">
<Background loadingOpacity={isLoading ? 1 : 0} />
const { x: mouseX, y: mouseY, vx } = getMousePos(frame);
{/* Main Stage */}
<div style={{ perspective: '1000px', zIndex: 10 }}>
{/* Button Container */}
<div
style={{
transform: `scale(${buttonScale})`,
transition: 'transform 0.1s'
}}
>
<Button
href="#"
variant="primary"
showArrow={!isLoading && !isSuccess}
className={`
// 3D Cursor Skew
const cursorSkew = interpolate(vx, [-20, 20], [20, -20], {
extrapolateRight: "clamp",
extrapolateLeft: "clamp",
});
const isClicking = frame >= CLICK_FRAME && frame < CLICK_FRAME + 8;
const clickRotate = isClicking ? 30 : 0;
// 2. Button State Logic
const isLoading = frame >= LOADING_START && frame < SUCCESS_START;
const isSuccess = frame >= SUCCESS_START;
// Loading Spinner Rotation
const spinnerRot = interpolate(
frame,
[LOADING_START, SUCCESS_START],
[0, 720],
);
// Button Scale Physics
const pressSpring = spring({
frame: frame - CLICK_FRAME,
fps,
config: { stiffness: 400, damping: 20 },
});
const successSpring = spring({
frame: frame - SUCCESS_START,
fps,
config: { stiffness: 200, damping: 15 },
});
// Morph scale: Click(Compress) -> Loading(Normal) -> Success(Pop)
let buttonScale = 1;
if (frame >= CLICK_FRAME && frame < LOADING_START) {
buttonScale = 1 - pressSpring * 0.05;
} else if (isSuccess) {
buttonScale = 1 + successSpring * 0.05;
}
// 3. Toast Animation
const toastSpring = spring({
frame: frame - TOAST_START,
fps,
config: { stiffness: 100, damping: 15 },
});
const toastExit = spring({
frame: frame - TOAST_END,
fps,
config: { stiffness: 100, damping: 20 },
});
const toastY =
interpolate(toastSpring, [0, 1], [100, -80]) +
interpolate(toastExit, [0, 1], [0, 200]);
const toastOpacity =
interpolate(toastSpring, [0, 1], [0, 1]) -
interpolate(toastExit, [0, 0.5], [0, 1]);
return (
<AbsoluteFill className="items-center justify-center overflow-hidden bg-white">
<Background loadingOpacity={isLoading ? 1 : 0} />
{/* Main Stage */}
<div style={{ perspective: "1000px", zIndex: 10 }}>
{/* Button Container */}
<div
style={{
transform: `scale(${buttonScale})`,
transition: "transform 0.1s",
}}
>
<Button
href="#"
variant="primary"
showArrow={!isLoading && !isSuccess}
className={`
!transition-all !duration-500
!px-16 !py-8 !text-2xl !font-bold
${isSuccess
? '!bg-white !text-slate-900 !border-slate-900 shadow-none' // Success: Outline/Minimal
: '!bg-slate-900 !text-white !border-none !shadow-2xl !shadow-slate-300' // Default/Load: Solid
${
isSuccess
? "!bg-white !text-slate-900 !border-slate-900 shadow-none" // Success: Outline/Minimal
: "!bg-slate-900 !text-white !border-none !shadow-2xl !shadow-slate-300" // Default/Load: Solid
}
!rounded-full
`}
>
<div className="flex items-center gap-4 min-w-[240px] justify-center text-center">
{/* Default State */}
{!isLoading && !isSuccess && (
<span className="animate-fade-in">Start Verification</span>
)}
>
<div className="flex items-center gap-4 min-w-[240px] justify-center text-center">
{/* Default State */}
{!isLoading && !isSuccess && (
<span className="animate-fade-in">Start Verification</span>
)}
{/* Loading State */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2
className="animate-spin text-slate-400"
size={32}
style={{ transform: `rotate(${spinnerRot}deg)` }}
/>
</div>
)}
{/* Success State */}
{isSuccess && (
<div className="flex items-center gap-3 animate-slide-up text-slate-900">
<Check size={28} strokeWidth={3} />
<span>Verified</span>
</div>
)}
</div>
</Button>
{/* Loading State */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2
className="animate-spin text-slate-400"
size={32}
style={{ transform: `rotate(${spinnerRot}deg)` }}
/>
</div>
</div>
)}
{/* Toast Notification Layer - Bottom Center */}
<div
className="absolute bottom-0 left-0 right-0 flex justify-center pb-20"
style={{
transform: `translateY(${toastY}px)`,
opacity: Math.max(0, toastOpacity)
}}
>
<Toast show={true} text="" />
{/* Success State */}
{isSuccess && (
<div className="flex items-center gap-3 animate-slide-up text-slate-900">
<Check size={28} strokeWidth={3} />
<span>Verified</span>
</div>
)}
</div>
</Button>
</div>
</div>
{/* 3D Cursor */}
<div
style={{
position: 'absolute',
top: 0, left: 0,
transform: `translate(${mouseX}px, ${mouseY}px) skewX(${cursorSkew}deg) rotateX(${clickRotate}deg)`,
zIndex: 1000,
pointerEvents: 'none'
}}
>
<MouseCursor x={0} y={0} isClicking={isClicking} />
</div>
</AbsoluteFill>
);
{/* Toast Notification Layer - Bottom Center */}
<div
className="absolute bottom-0 left-0 right-0 flex justify-center pb-20"
style={{
transform: `translateY(${toastY}px)`,
opacity: Math.max(0, toastOpacity),
}}
>
<Toast _show={true} _text="" />
</div>
{/* 3D Cursor */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
transform: `translate(${mouseX}px, ${mouseY}px) skewX(${cursorSkew}deg) rotateX(${clickRotate}deg)`,
zIndex: 1000,
pointerEvents: "none",
}}
>
<MouseCursor x={0} y={0} isClicking={isClicking} />
</div>
</AbsoluteFill>
);
};

View File

@@ -1,292 +1,339 @@
import React, { useMemo, useEffect, useState } from 'react';
import React, { useMemo, useEffect, useState } from "react";
import {
AbsoluteFill,
interpolate,
useCurrentFrame,
useVideoConfig,
Easing,
Img,
delayRender,
continueRender,
spring,
Audio,
staticFile,
} from 'remotion';
import { MouseCursor } from '../components/MouseCursor';
import { ContactForm } from '@/src/components/ContactForm';
import { BackgroundGrid } from '@/src/components/Layout';
import { initialState } from '@/src/components/ContactForm/constants';
AbsoluteFill,
interpolate,
useCurrentFrame,
useVideoConfig,
delayRender,
continueRender,
spring,
Img,
} from "remotion";
/* eslint-disable no-unused-vars */
import { MouseCursor } from "../components/MouseCursor";
import { ContactForm } from "@/src/components/ContactForm";
import { BackgroundGrid } from "@/src/components/Layout";
import { initialState } from "@/src/components/ContactForm/constants";
// Brand Assets
import IconWhite from '@/src/assets/logo/Icon White Transparent.svg';
import IconWhite from "@/src/assets/logo/Icon White Transparent.svg";
export const ContactFormShowcase: React.FC = () => {
const frame = useCurrentFrame();
const { width, height, fps } = useVideoConfig();
const [handle] = useState(() => delayRender('Initializing Deep Interaction Script'));
const frame = useCurrentFrame();
const { width, height, fps } = useVideoConfig();
const [handle] = useState(() =>
delayRender("Initializing Deep Interaction Script"),
);
useEffect(() => {
if (typeof window !== 'undefined') (window as any).isRemotion = true;
const timer = setTimeout(() => continueRender(handle), 500);
return () => clearTimeout(timer);
}, [handle]);
useEffect(() => {
if (typeof window !== "undefined") (window as any).isRemotion = true;
const timer = setTimeout(() => continueRender(handle), 500);
return () => clearTimeout(timer);
}, [handle]);
// ---- TIMELINE CONSTANTS (1500 Frames / 25s) ----
const T = useMemo(() => ({
ENTER: 0,
// Step 0: Type Selection
SELECT_WEBSITE: 60,
NEXT_0: 100,
// ---- TIMELINE CONSTANTS (1500 Frames / 25s) ----
const T = useMemo(
() => ({
ENTER: 0,
// Step 0: Type Selection
SELECT_WEBSITE: 60,
NEXT_0: 100,
// Step 1: Company Profile
COMPANY_TYPE_START: 150,
COMPANY_TYPE_END: 250,
NEXT_1: 300,
// Step 1: Company Profile
COMPANY_TYPE_START: 150,
COMPANY_TYPE_END: 250,
NEXT_1: 300,
// Step 2: Presence
URL_TYPE_START: 350,
URL_TYPE_END: 450,
NEXT_2: 500,
// Step 2: Presence
URL_TYPE_START: 350,
URL_TYPE_END: 450,
NEXT_2: 500,
// Step 3: Scope & Services (Multi-clicks)
SCOPE_CLICK_1: 550,
SCOPE_CLICK_2: 600,
SCOPE_CLICK_3: 650,
NEXT_3: 750,
// Step 3: Scope & Services (Multi-clicks)
SCOPE_CLICK_1: 550,
SCOPE_CLICK_2: 600,
SCOPE_CLICK_3: 650,
NEXT_3: 750,
// Step 4: Design Vibe
VIBE_SELECT: 850,
NEXT_4: 950,
// Step 4: Design Vibe
VIBE_SELECT: 850,
NEXT_4: 950,
// Step 5: Contact Info
NAME_TYPE_START: 1050,
NAME_TYPE_END: 1120,
EMAIL_TYPE_START: 1150,
EMAIL_TYPE_END: 1250,
MESSAGE_TYPE_START: 1280,
MESSAGE_TYPE_END: 1400,
SUBMIT: 1450,
// Step 5: Contact Info
NAME_TYPE_START: 1050,
NAME_TYPE_END: 1120,
EMAIL_TYPE_START: 1150,
EMAIL_TYPE_END: 1250,
MESSAGE_TYPE_START: 1280,
MESSAGE_TYPE_END: 1400,
SUBMIT: 1450,
EXIT: 1500,
}), []);
EXIT: 1500,
}),
[],
);
// ---- FORM STATE LOGIC ----
const formState = useMemo(() => {
const state = { ...initialState };
// ---- FORM STATE LOGIC ----
const formState = useMemo(() => {
const state = { ...initialState };
// Step 0: Fixed to website per request
state.projectType = 'website';
// Step 0: Fixed to website per request
state.projectType = "website";
// Step 1: Company
if (frame > T.COMPANY_TYPE_START) {
const text = "Mintel Studios";
const progress = interpolate(frame, [T.COMPANY_TYPE_START, T.COMPANY_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
state.companyName = text.substring(0, Math.round(progress));
}
// Step 1: Company
if (frame > T.COMPANY_TYPE_START) {
const text = "Mintel Studios";
const progress = interpolate(
frame,
[T.COMPANY_TYPE_START, T.COMPANY_TYPE_END],
[0, text.length],
{ extrapolateRight: "clamp" },
);
state.companyName = text.substring(0, Math.round(progress));
}
// Step 2: URL
if (frame > T.URL_TYPE_START) {
const text = "mintel.me";
const progress = interpolate(frame, [T.URL_TYPE_START, T.URL_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
state.existingWebsite = text.substring(0, Math.round(progress));
}
// Step 2: URL
if (frame > T.URL_TYPE_START) {
const text = "mintel.me";
const progress = interpolate(
frame,
[T.URL_TYPE_START, T.URL_TYPE_END],
[0, text.length],
{ extrapolateRight: "clamp" },
);
state.existingWebsite = text.substring(0, Math.round(progress));
}
// Step 3: Selections
if (frame > T.SCOPE_CLICK_1) state.selectedPages = ['Home', 'About'];
if (frame > T.SCOPE_CLICK_2) state.selectedPages = ['Home', 'About', 'Services'];
if (frame > T.SCOPE_CLICK_3) state.features = ['blog_news'];
// Step 3: Selections
if (frame > T.SCOPE_CLICK_1) state.selectedPages = ["Home", "About"];
if (frame > T.SCOPE_CLICK_2)
state.selectedPages = ["Home", "About", "Services"];
if (frame > T.SCOPE_CLICK_3) state.features = ["blog_news"];
// Step 4: Design
if (frame > T.VIBE_SELECT) state.designVibe = 'tech';
// Step 4: Design
if (frame > T.VIBE_SELECT) state.designVibe = "tech";
// Step 5: Contact
if (frame > T.NAME_TYPE_START) {
const text = "Marc Mintel";
const progress = interpolate(frame, [T.NAME_TYPE_START, T.NAME_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
state.name = text.substring(0, Math.round(progress));
}
if (frame > T.EMAIL_TYPE_START) {
const text = "marc@mintel.me";
const progress = interpolate(frame, [T.EMAIL_TYPE_START, T.EMAIL_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
state.email = text.substring(0, Math.round(progress));
}
if (frame > T.MESSAGE_TYPE_START) {
const text = "Hi folks! Let's build something cinematic and smooth.";
const progress = interpolate(frame, [T.MESSAGE_TYPE_START, T.MESSAGE_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
state.message = text.substring(0, Math.round(progress));
}
// Step 5: Contact
if (frame > T.NAME_TYPE_START) {
const text = "Marc Mintel";
const progress = interpolate(
frame,
[T.NAME_TYPE_START, T.NAME_TYPE_END],
[0, text.length],
{ extrapolateRight: "clamp" },
);
state.name = text.substring(0, Math.round(progress));
}
if (frame > T.EMAIL_TYPE_START) {
const text = "marc@mintel.me";
const progress = interpolate(
frame,
[T.EMAIL_TYPE_START, T.EMAIL_TYPE_END],
[0, text.length],
{ extrapolateRight: "clamp" },
);
state.email = text.substring(0, Math.round(progress));
}
if (frame > T.MESSAGE_TYPE_START) {
const text = "Hi folks! Let's build something cinematic and smooth.";
const progress = interpolate(
frame,
[T.MESSAGE_TYPE_START, T.MESSAGE_TYPE_END],
[0, text.length],
{ extrapolateRight: "clamp" },
);
state.message = text.substring(0, Math.round(progress));
}
return state;
}, [frame, T]);
return state;
}, [frame, T]);
// ---- STEP NAVIGATION ----
const stepIndex = useMemo(() => {
if (frame < T.NEXT_0) return 0;
if (frame < T.NEXT_1) return 1;
if (frame < T.NEXT_2) return 2;
if (frame < T.NEXT_3) return 3;
if (frame < T.NEXT_4) return 6; // Mapping depends on actual component order, let's assume standard sequence
if (frame < T.SUBMIT) return 12; // Final Contact step
return 13; // Success
}, [frame, T]);
// ---- STEP NAVIGATION ----
const stepIndex = useMemo(() => {
if (frame < T.NEXT_0) return 0;
if (frame < T.NEXT_1) return 1;
if (frame < T.NEXT_2) return 2;
if (frame < T.NEXT_3) return 3;
if (frame < T.NEXT_4) return 6; // Mapping depends on actual component order, let's assume standard sequence
if (frame < T.SUBMIT) return 12; // Final Contact step
return 13; // Success
}, [frame, T]);
// ---- CAMERA ANCHORS ----
const anchors = useMemo(() => ({
'overview': { z: 0.85, x: 0, y: 0 },
'type': { z: 1.15, x: 250, y: 150 },
'company': { z: 1.3, x: 100, y: 50 },
'presence': { z: 1.3, x: 100, y: -50 },
'scope': { z: 1.1, x: -100, y: 0 },
'design': { z: 1.15, x: 150, y: 100 },
'contact': { z: 1.25, x: 0, y: 400 },
'success': { z: 0.9, x: 0, y: 0 },
}), []);
// ---- CAMERA ANCHORS ----
const anchors = useMemo(
() => ({
overview: { z: 0.85, x: 0, y: 0 },
type: { z: 1.15, x: 250, y: 150 },
company: { z: 1.3, x: 100, y: 50 },
presence: { z: 1.3, x: 100, y: -50 },
scope: { z: 1.1, x: -100, y: 0 },
design: { z: 1.15, x: 150, y: 100 },
contact: { z: 1.25, x: 0, y: 400 },
success: { z: 0.9, x: 0, y: 0 },
}),
[],
);
const activeAnchor = useMemo(() => {
if (frame < T.SELECT_WEBSITE) return anchors.overview;
if (frame < T.NEXT_0) return anchors.type;
if (frame < T.NEXT_1) return anchors.company;
if (frame < T.NEXT_2) return anchors.presence;
if (frame < T.NEXT_3) return anchors.scope;
if (frame < T.NEXT_4) return anchors.design;
if (frame < T.SUBMIT) return anchors.contact;
return anchors.success;
}, [frame, anchors, T]);
const activeAnchor = useMemo(() => {
if (frame < T.SELECT_WEBSITE) return anchors.overview;
if (frame < T.NEXT_0) return anchors.type;
if (frame < T.NEXT_1) return anchors.company;
if (frame < T.NEXT_2) return anchors.presence;
if (frame < T.NEXT_3) return anchors.scope;
if (frame < T.NEXT_4) return anchors.design;
if (frame < T.SUBMIT) return anchors.contact;
return anchors.success;
}, [frame, anchors, T]);
const camera = useMemo(() => {
// Continuous organic spring follow
const s = spring({
frame,
fps,
config: { stiffness: 45, damping: 20 },
});
const _camera = useMemo(() => {
// Continuous organic spring follow
const _s = spring({
frame,
fps,
config: { stiffness: 45, damping: 20 },
});
// This is a simplified lerp since spring() is stateless per frame in remotion,
// for true chasing we'd need a custom reducer or just accept the "settle" behavior.
// Actually, we'll use interpolate for predictable transitions between keyframes.
return activeAnchor;
}, [frame, activeAnchor, fps]);
return activeAnchor;
}, [frame, activeAnchor, fps]);
// Simple smooth camera interpolation for the actual movement
const smoothCamera = useMemo(() => {
// To avoid jumpiness when anchor switches, we could use a custom useSpring alternative,
// but for now let's just use the active anchor and let the frame-based spring handle the property drift if planned.
// Actually, let's just use Interpolation for reliability.
return activeAnchor;
}, [activeAnchor]);
// Simple smooth camera interpolation for the actual movement
const smoothCamera = useMemo(() => {
return activeAnchor;
}, [activeAnchor]);
// ---- MOUSE PATH ----
const mouse = useMemo(() => {
const targets = {
off: { x: width * 1.2, y: height * 1.2 },
type_website: { x: 200, y: 50 },
btn_next: { x: 450, y: 450 },
company_input: { x: 0, y: 0 },
url_input: { x: 0, y: -50 },
scope_1: { x: -300, y: -100 },
scope_2: { x: -300, y: 0 },
scope_3: { x: 0, y: 200 },
vibe_tech: { x: 250, y: 100 },
contact_name: { x: -200, y: 200 },
contact_email: { x: 200, y: 200 },
contact_msg: { x: 0, y: 400 },
btn_submit: { x: 400, y: 550 },
};
// ---- MOUSE PATH ----
const mouse = useMemo(() => {
const targets = {
off: { x: width * 1.2, y: height * 1.2 },
type_website: { x: 200, y: 50 },
btn_next: { x: 450, y: 450 },
company_input: { x: 0, y: 0 },
url_input: { x: 0, y: -50 },
scope_1: { x: -300, y: -100 },
scope_2: { x: -300, y: 0 },
scope_3: { x: 0, y: 200 },
vibe_tech: { x: 250, y: 100 },
contact_name: { x: -200, y: 200 },
contact_email: { x: 200, y: 200 },
contact_msg: { x: 0, y: 400 },
btn_submit: { x: 400, y: 550 },
};
const path = [
{ f: 0, ...targets.off },
{ f: T.SELECT_WEBSITE, ...targets.type_website },
{ f: T.NEXT_0, ...targets.btn_next },
{ f: T.COMPANY_TYPE_START, ...targets.company_input },
{ f: T.NEXT_1, ...targets.btn_next },
{ f: T.URL_TYPE_START, ...targets.url_input },
{ f: T.NEXT_2, ...targets.btn_next },
{ f: T.SCOPE_CLICK_1, ...targets.scope_1 },
{ f: T.SCOPE_CLICK_2, ...targets.scope_2 },
{ f: T.SCOPE_CLICK_3, ...targets.scope_3 },
{ f: T.NEXT_3, ...targets.btn_next },
{ f: T.VIBE_SELECT, ...targets.vibe_tech },
{ f: T.NEXT_4, ...targets.btn_next },
{ f: T.NAME_TYPE_START, ...targets.contact_name },
{ f: T.EMAIL_TYPE_START, ...targets.contact_email },
{ f: T.MESSAGE_TYPE_START, ...targets.contact_msg },
{ f: T.SUBMIT, ...targets.btn_submit },
{ f: T.EXIT, ...targets.off },
];
const path = [
{ f: 0, ...targets.off },
{ f: T.SELECT_WEBSITE, ...targets.type_website },
{ f: T.NEXT_0, ...targets.btn_next },
{ f: T.COMPANY_TYPE_START, ...targets.company_input },
{ f: T.NEXT_1, ...targets.btn_next },
{ f: T.URL_TYPE_START, ...targets.url_input },
{ f: T.NEXT_2, ...targets.btn_next },
{ f: T.SCOPE_CLICK_1, ...targets.scope_1 },
{ f: T.SCOPE_CLICK_2, ...targets.scope_2 },
{ f: T.SCOPE_CLICK_3, ...targets.scope_3 },
{ f: T.NEXT_3, ...targets.btn_next },
{ f: T.VIBE_SELECT, ...targets.vibe_tech },
{ f: T.NEXT_4, ...targets.btn_next },
{ f: T.NAME_TYPE_START, ...targets.contact_name },
{ f: T.EMAIL_TYPE_START, ...targets.contact_email },
{ f: T.MESSAGE_TYPE_START, ...targets.contact_msg },
{ f: T.SUBMIT, ...targets.btn_submit },
{ f: T.EXIT, ...targets.off },
];
let idx = 0;
for (let i = 0; i < path.length; i++) {
if (frame >= path[i].f) idx = i;
}
let idx = 0;
for (let i = 0; i < path.length; i++) {
if (frame >= path[i].f) idx = i;
}
const p1 = path[idx];
const p2 = path[idx + 1] || p1;
const p1 = path[idx];
const p2 = path[idx + 1] || p1;
const s = spring({
frame: frame - p1.f,
fps,
config: { stiffness: 60, damping: 25 },
});
const s = spring({
frame: frame - p1.f,
fps,
config: { stiffness: 60, damping: 25 },
});
return {
x: interpolate(s, [0, 1], [p1.x, p2.x]),
y: interpolate(s, [0, 1], [p1.y, p2.y]),
};
}, [frame, width, height, fps, T]);
return {
x: interpolate(s, [0, 1], [p1.x, p2.x]),
y: interpolate(s, [0, 1], [p1.y, p2.y]),
};
}, [frame, width, height, fps, T]);
const isClicking = useMemo(() => {
const clicks = [
T.SELECT_WEBSITE, T.NEXT_0, T.NEXT_1, T.NEXT_2,
T.SCOPE_CLICK_1, T.SCOPE_CLICK_2, T.SCOPE_CLICK_3, T.NEXT_3,
T.VIBE_SELECT, T.NEXT_4, T.SUBMIT
];
return clicks.some(c => frame >= c && frame < c + 8);
}, [frame, T]);
const isClicking = useMemo(() => {
const clicks = [
T.SELECT_WEBSITE,
T.NEXT_0,
T.NEXT_1,
T.NEXT_2,
T.SCOPE_CLICK_1,
T.SCOPE_CLICK_2,
T.SCOPE_CLICK_3,
T.NEXT_3,
T.VIBE_SELECT,
T.NEXT_4,
T.SUBMIT,
];
return clicks.some((c) => frame >= c && frame < c + 8);
}, [frame, T]);
return (
<AbsoluteFill className="bg-white">
<BackgroundGrid />
return (
<AbsoluteFill className="bg-white">
<BackgroundGrid />
<style>{`
<style>{`
* { transition: none !important; animation: none !important; -webkit-font-smoothing: antialiased; }
.focus-layer { transform-style: preserve-3d; backface-visibility: hidden; will-change: transform; }
`}</style>
<div
className="absolute inset-0 flex items-center justify-center focus-layer"
style={{
transform: `translate3d(${Math.round(smoothCamera.x)}px, ${Math.round(smoothCamera.y)}px, 0) scale(${smoothCamera.z.toFixed(4)})`,
}}
>
<div style={{ transform: 'scale(0.85) translate3d(0,0,0)', width: '1200px' }} className="focus-layer">
<ContactForm initialStepIndex={stepIndex} initialState={formState} />
</div>
<div
className="absolute inset-0 flex items-center justify-center focus-layer"
style={{
transform: `translate3d(${Math.round(smoothCamera.x)}px, ${Math.round(smoothCamera.y)}px, 0) scale(${smoothCamera.z.toFixed(4)})`,
}}
>
<div
style={{
transform: "scale(0.85) translate3d(0,0,0)",
width: "1200px",
}}
className="focus-layer"
>
<ContactForm initialStepIndex={stepIndex} initialState={formState} />
</div>
<div
style={{
position: 'absolute',
top: '50%', left: '50%',
transform: `translate3d(${Math.round(mouse.x)}px, ${Math.round(mouse.y)}px, 0)`,
zIndex: 1000,
marginTop: -12, marginLeft: -6,
}}
className="focus-layer"
>
<MouseCursor isClicking={isClicking} x={0} y={0} />
</div>
</div>
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: `translate3d(${Math.round(mouse.x)}px, ${Math.round(mouse.y)}px, 0)`,
zIndex: 1000,
marginTop: -12,
marginLeft: -6,
}}
className="focus-layer"
>
<MouseCursor isClicking={isClicking} x={0} y={0} />
</div>
</div>
{/* Logo HUD */}
<div className="absolute top-28 left-28 z-50 focus-layer">
<div className="w-40 h-40 bg-black rounded-[3.5rem] flex items-center justify-center shadow-2xl">
<Img src={IconWhite} className="w-24 h-24" />
</div>
</div>
{/* Logo HUD */}
<div className="absolute top-28 left-28 z-50 focus-layer">
<div className="w-40 h-40 bg-black rounded-[3.5rem] flex items-center justify-center shadow-2xl">
<Img src={IconWhite} className="w-24 h-24" />
</div>
</div>
<AbsoluteFill
className="bg-white pointer-events-none"
style={{ opacity: interpolate(frame, [0, 15], [1, 0], { extrapolateRight: 'clamp' }) }}
/>
</AbsoluteFill>
);
<AbsoluteFill
className="bg-white pointer-events-none"
style={{
opacity: interpolate(frame, [0, 15], [1, 0], {
extrapolateRight: "clamp",
}),
}}
/>
</AbsoluteFill>
);
};

View File

@@ -1,85 +1,113 @@
import React from 'react';
/* eslint-disable no-unused-vars */
import React from "react";
// ULTRA-CRITICAL ANIMATION KILLER
// This mock covers all possible Framer Motion V12 entry points
// and forces absolute determinism on both HTML and SVG elements.
const createMotionComponent = (Tag: string) => {
const Component = React.forwardRef(({
children, style, animate, initial, whileHover, whileTap,
transition, layout, layoutId,
variants, ...props
}: any, ref) => {
const Component = React.forwardRef(
(
{
children,
style,
animate,
initial,
_whileHover,
_whileTap,
_transition,
_layout,
_layoutId,
_variants,
...props
}: any,
ref,
) => {
// 1. Resolve State
// If animate is a string (variant), we try to find it in variants,
// but since we want to be deterministic, we just ignore variants for now
// to avoid complex logic. We assume the component state is driven by props.
// 1. Resolve State
// If animate is a string (variant), we try to find it in variants,
// but since we want to be deterministic, we just ignore variants for now
// to avoid complex logic. We assume the component state is driven by props.
// 2. Resolve Attributes (for SVG)
// Framer motion allows animating SVG attributes like 'r', 'cx' directly.
// We must spread 'animate' into the props to snap them.
const resolvedProps = { ...props };
if (typeof animate === "object" && !Array.isArray(animate)) {
Object.assign(resolvedProps, animate);
} else if (Array.isArray(animate)) {
// Handle keyframes by taking the first one
Object.assign(resolvedProps, animate[0]);
}
// 2. Resolve Attributes (for SVG)
// Framer motion allows animating SVG attributes like 'r', 'cx' directly.
// We must spread 'animate' into the props to snap them.
const resolvedProps = { ...props };
if (typeof animate === 'object' && !Array.isArray(animate)) {
Object.assign(resolvedProps, animate);
} else if (Array.isArray(animate)) {
// Handle keyframes by taking the first one
Object.assign(resolvedProps, animate[0]);
}
// 3. Resolve Style
const combinedStyle = {
...style,
...(typeof initial === "object" && !Array.isArray(initial)
? initial
: {}),
...(typeof animate === "object" && !Array.isArray(animate)
? animate
: {}),
};
// 3. Resolve Style
const combinedStyle = {
...style,
...(typeof initial === 'object' && !Array.isArray(initial) ? initial : {}),
...(typeof animate === 'object' && !Array.isArray(animate) ? animate : {})
};
// Final cleaning of motion-specific props that shouldn't leak to DOM
const {
_viewport,
transition: __t,
_onAnimationStart,
_onAnimationComplete,
_onUpdate,
_onPan,
_onPanStart,
_onPanEnd,
_onPanSessionStart,
_onTap,
_onTapStart,
_onTapCancel,
_onHoverStart,
_onHoverEnd,
...domProps
} = resolvedProps;
// Final cleaning of motion-specific props that shouldn't leak to DOM
const {
viewport, transition: _t, onAnimationStart, onAnimationComplete,
onUpdate, onPan, onPanStart, onPanEnd, onPanSessionStart,
onTap, onTapStart, onTapCancel, onHoverStart, onHoverEnd,
...domProps
} = resolvedProps;
return (
<Tag
ref={ref}
{...domProps}
style={combinedStyle}
data-framer-captured="true"
>
{children}
</Tag>
);
});
Component.displayName = `motion.${Tag}`;
return Component;
return (
<Tag
ref={ref}
{...domProps}
style={combinedStyle}
data-framer-captured="true"
>
{children}
</Tag>
);
},
);
Component.displayName = `motion.${Tag}`;
return Component;
};
export const motion: any = {
div: createMotionComponent('div'),
button: createMotionComponent('button'),
h1: createMotionComponent('h1'),
h2: createMotionComponent('h2'),
h3: createMotionComponent('h3'),
h4: createMotionComponent('h4'),
p: createMotionComponent('p'),
span: createMotionComponent('span'),
section: createMotionComponent('section'),
nav: createMotionComponent('nav'),
svg: createMotionComponent('svg'),
path: createMotionComponent('path'),
circle: createMotionComponent('circle'),
rect: createMotionComponent('rect'),
line: createMotionComponent('line'),
polyline: createMotionComponent('polyline'),
polygon: createMotionComponent('polygon'),
ellipse: createMotionComponent('ellipse'),
g: createMotionComponent('g'),
a: createMotionComponent('a'),
li: createMotionComponent('li'),
ul: createMotionComponent('ul'),
div: createMotionComponent("div"),
button: createMotionComponent("button"),
h1: createMotionComponent("h1"),
h2: createMotionComponent("h2"),
h3: createMotionComponent("h3"),
h4: createMotionComponent("h4"),
p: createMotionComponent("p"),
span: createMotionComponent("span"),
section: createMotionComponent("section"),
nav: createMotionComponent("nav"),
svg: createMotionComponent("svg"),
path: createMotionComponent("path"),
circle: createMotionComponent("circle"),
rect: createMotionComponent("rect"),
line: createMotionComponent("line"),
polyline: createMotionComponent("polyline"),
polygon: createMotionComponent("polygon"),
ellipse: createMotionComponent("ellipse"),
g: createMotionComponent("g"),
a: createMotionComponent("a"),
li: createMotionComponent("li"),
ul: createMotionComponent("ul"),
};
export const m = motion;
@@ -89,21 +117,25 @@ export const LayoutGroup = ({ children }: any) => <>{children}</>;
export const LazyMotion = ({ children }: any) => <>{children}</>;
export const useAnimation = () => ({
start: () => Promise.resolve(),
set: () => { },
stop: () => { },
mount: () => { },
start: () => Promise.resolve(),
set: () => {},
stop: () => {},
mount: () => {},
});
export const useInView = () => true;
export const useScroll = () => ({
scrollYProgress: { get: () => 0, onChange: () => () => { }, getVelocity: () => 0 },
scrollY: { get: () => 0, onChange: () => () => { }, getVelocity: () => 0 }
scrollYProgress: {
get: () => 0,
onChange: () => () => {},
getVelocity: () => 0,
},
scrollY: { get: () => 0, onChange: () => () => {}, getVelocity: () => 0 },
});
export const useTransform = (value: any, from: any[], to: any[]) => to[0];
export const useTransform = (_value: any, _from: any[], to: any[]) => to[0];
export const useSpring = (value: any) => value;
export const useCycle = (...args: any[]) => [args[0], () => { }];
export const useCycle = (...args: any[]) => [args[0], () => {}];
export const useIsPresent = () => true;
export const useReducedMotion = () => true;
export const useAnimationControls = useAnimation;