chore: stabilize apps/web (lint, build, typecheck fixes)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 1m27s
Build & Deploy / 🏗️ Build (push) Failing after 1m31s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 1m27s
Build & Deploy / 🏗️ Build (push) Failing after 1m31s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
This commit is contained in:
@@ -1,23 +1,28 @@
|
|||||||
import * as React from 'react';
|
import Image from "next/image";
|
||||||
import Image from 'next/image';
|
import { PageHeader } from "../../src/components/PageHeader";
|
||||||
import { PageHeader } from '../../src/components/PageHeader';
|
import { Section } from "../../src/components/Section";
|
||||||
import { Section } from '../../src/components/Section';
|
import { Reveal } from "../../src/components/Reveal";
|
||||||
import { Reveal } from '../../src/components/Reveal';
|
|
||||||
import {
|
import {
|
||||||
ExperienceIllustration,
|
ExperienceIllustration,
|
||||||
ResponsibilityIllustration,
|
ResponsibilityIllustration,
|
||||||
ResultIllustration,
|
ResultIllustration,
|
||||||
ConceptSystem,
|
ConceptSystem,
|
||||||
ConceptTarget,
|
|
||||||
ContactIllustration,
|
ContactIllustration,
|
||||||
HeroLines,
|
HeroLines,
|
||||||
ParticleNetwork,
|
ParticleNetwork,
|
||||||
GridLines
|
GridLines,
|
||||||
} from '../../src/components/Landing';
|
} from "../../src/components/Landing";
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from "lucide-react";
|
||||||
import { H3, H4, LeadText, BodyText, Label, MonoLabel } from '../../src/components/Typography';
|
import {
|
||||||
import { BackgroundGrid, Card, Container } from '../../src/components/Layout';
|
H3,
|
||||||
import { Button } from '../../src/components/Button';
|
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() {
|
export default function AboutPage() {
|
||||||
return (
|
return (
|
||||||
@@ -45,9 +50,11 @@ export default function AboutPage() {
|
|||||||
|
|
||||||
<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="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">
|
<div className="w-full h-full rounded-full overflow-hidden">
|
||||||
<img
|
<Image
|
||||||
src="/header.webp"
|
src="/header.webp"
|
||||||
alt="Marc Mintel"
|
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"
|
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>
|
</div>
|
||||||
@@ -59,12 +66,18 @@ export default function AboutPage() {
|
|||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<div className="flex items-center justify-center gap-4 mb-4">
|
<div className="flex items-center justify-center gap-4 mb-4">
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={<>Über <span className="text-slate-200">mich.</span></>}
|
title={
|
||||||
|
<>
|
||||||
|
Über <span className="text-slate-200">mich.</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
description="Warum ich tue, was ich tue – und wie Sie davon profitieren."
|
description="Warum ich tue, was ich tue – und wie Sie davon profitieren."
|
||||||
className="pt-0 md:pt-0"
|
className="pt-0 md:pt-0"
|
||||||
/>
|
/>
|
||||||
@@ -87,7 +100,9 @@ export default function AboutPage() {
|
|||||||
<Reveal>
|
<Reveal>
|
||||||
<H3 className="text-3xl md:text-5xl leading-tight max-w-3xl">
|
<H3 className="text-3xl md:text-5xl leading-tight max-w-3xl">
|
||||||
15 Jahre Web-Entwicklung. <br />
|
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>
|
</H3>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
@@ -95,13 +110,17 @@ export default function AboutPage() {
|
|||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<LeadText className="text-xl md:text-2xl text-slate-400">
|
<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>
|
</LeadText>
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
{[
|
{[
|
||||||
'Komplexe Systeme vereinfacht',
|
"Komplexe Systeme vereinfacht",
|
||||||
'Performance-Probleme gelöst',
|
"Performance-Probleme gelöst",
|
||||||
'Nachhaltige Software-Architekturen gebaut'
|
"Nachhaltige Software-Architekturen gebaut",
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<li key={i} className="flex items-center gap-4 group">
|
<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" />
|
<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>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={0.2}>
|
<Reveal delay={0.2}>
|
||||||
<Card variant="gray" hover={false} padding="normal" className="group">
|
<Card
|
||||||
<H4 className="text-2xl mb-6">Mein Fokus heute: Direkte Zusammenarbeit ohne Reibungsverluste.</H4>
|
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">
|
<div className="flex flex-wrap gap-3">
|
||||||
{['Effizient', 'Pragmatisch', 'Verlässlich'].map((tag, i) => (
|
{["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">
|
<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>
|
<Label className="text-slate-900">{tag}</Label>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -147,14 +177,24 @@ export default function AboutPage() {
|
|||||||
<div className="md:col-span-8 space-y-8">
|
<div className="md:col-span-8 space-y-8">
|
||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<LeadText className="text-xl md:text-2xl text-slate-400">
|
<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>
|
</LeadText>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={0.2}>
|
<Reveal delay={0.2}>
|
||||||
<Card variant="white" padding="normal" className="flex flex-row items-start gap-6 group">
|
<Card
|
||||||
<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>
|
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">
|
<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>
|
</BodyText>
|
||||||
</Card>
|
</Card>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -182,11 +222,20 @@ export default function AboutPage() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<LeadText className="text-xl text-slate-400">
|
<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>
|
</LeadText>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<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}>
|
<Reveal key={i} delay={0.2 + i * 0.05}>
|
||||||
<div className="flex items-center gap-3 group">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Reveal delay={0.3}>
|
<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" />
|
<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">
|
<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>
|
</LeadText>
|
||||||
</Card>
|
</Card>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -231,9 +287,19 @@ export default function AboutPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Label>Kein:</Label>
|
<Label>Kein:</Label>
|
||||||
<div className="flex flex-wrap gap-3">
|
<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">
|
"Agentur-Zirkus",
|
||||||
<BodyText className="text-slate-400 line-through text-base mb-0">{item}</BodyText>
|
"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>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -244,9 +310,18 @@ export default function AboutPage() {
|
|||||||
<Label className="text-slate-900">Sondern:</Label>
|
<Label className="text-slate-900">Sondern:</Label>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{[
|
{[
|
||||||
{ label: 'Direkte Kommunikation', desc: 'Kurze Wege, schnelle Entscheidungen.' },
|
{
|
||||||
{ label: 'Echte Expertise', desc: 'Fundiertes Wissen aus 15 Jahren Praxis.' },
|
label: "Direkte Kommunikation",
|
||||||
{ label: 'Messbare Qualität', desc: 'Code, der hält, was er verspricht.' }
|
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) => (
|
].map((item, i) => (
|
||||||
<Reveal key={i} delay={0.2 + i * 0.1}>
|
<Reveal key={i} delay={0.2 + i * 0.1}>
|
||||||
<div className="flex gap-6 items-start group">
|
<div className="flex gap-6 items-start group">
|
||||||
@@ -255,7 +330,9 @@ export default function AboutPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<H4 className="text-xl">{item.label}</H4>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -283,18 +360,22 @@ export default function AboutPage() {
|
|||||||
</H3>
|
</H3>
|
||||||
</Reveal>
|
</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="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">
|
<div className="relative z-10 space-y-8">
|
||||||
<LeadText className="text-2xl md:text-4xl leading-tight max-w-2xl text-slate-400">
|
<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>
|
</LeadText>
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<Button href="/contact">
|
<Button href="/contact">Projekt anfragen</Button>
|
||||||
Projekt anfragen
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from "next/navigation";
|
||||||
import { blogPosts } from '../../../src/data/blogPosts';
|
import { blogPosts } from "../../../src/data/blogPosts";
|
||||||
import { Tag } from '../../../src/components/Tag';
|
import { Tag } from "../../../src/components/Tag";
|
||||||
import { CodeBlock } from '../../../src/components/ArticleBlockquote';
|
import { CodeBlock } from "../../../src/components/ArticleBlockquote";
|
||||||
import { H2 } from '../../../src/components/ArticleHeading';
|
import { H2 } from "../../../src/components/ArticleHeading";
|
||||||
import { Paragraph, LeadParagraph } from '../../../src/components/ArticleParagraph';
|
import {
|
||||||
import { UL, LI } from '../../../src/components/ArticleList';
|
Paragraph,
|
||||||
import { FileExamplesList } from '../../../src/components/FileExamplesList';
|
LeadParagraph,
|
||||||
import { FileExampleManager } from '../../../src/data/fileExamples';
|
} from "../../../src/components/ArticleParagraph";
|
||||||
import { BlogPostClient } from '../../../src/components/BlogPostClient';
|
import { UL, LI } from "../../../src/components/ArticleList";
|
||||||
import { PageHeader } from '../../../src/components/PageHeader';
|
import { FileExamplesList } from "../../../src/components/FileExamplesList";
|
||||||
import { Section } from '../../../src/components/Section';
|
import { FileExampleManager } from "../../../src/data/fileExamples";
|
||||||
import { Reveal } from '../../../src/components/Reveal';
|
import { BlogPostClient } from "../../../src/components/BlogPostClient";
|
||||||
|
import { PageHeader } from "../../../src/components/PageHeader";
|
||||||
|
import { Section } from "../../../src/components/Section";
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
return blogPosts.map((post) => ({
|
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 { slug } = await params;
|
||||||
const post = blogPosts.find((p) => p.slug === slug);
|
const post = blogPosts.find((p) => p.slug === slug);
|
||||||
|
|
||||||
@@ -27,17 +33,23 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedDate = new Date(post.date).toLocaleDateString('en-US', {
|
const formattedDate = new Date(post.date).toLocaleDateString("en-US", {
|
||||||
month: 'long',
|
month: "long",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
year: 'numeric'
|
year: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
const wordCount = post.description.split(/\s+/).length + 100;
|
const wordCount = post.description.split(/\s+/).length + 100;
|
||||||
const readingTime = Math.max(1, Math.ceil(wordCount / 200));
|
const readingTime = Math.max(1, Math.ceil(wordCount / 200));
|
||||||
|
|
||||||
const showFileExamples = post.tags?.some(tag =>
|
const showFileExamples = post.tags?.some((tag) =>
|
||||||
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
|
[
|
||||||
|
"architecture",
|
||||||
|
"design-patterns",
|
||||||
|
"system-design",
|
||||||
|
"docker",
|
||||||
|
"deployment",
|
||||||
|
].includes(tag),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load file examples for the post
|
// Load file examples for the post
|
||||||
@@ -62,7 +74,7 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title={post.title}
|
title={post.title}
|
||||||
description={post.description}
|
description={post.description}
|
||||||
backLink={{ href: '/blog', label: 'Zurück zum Blog' }}
|
backLink={{ href: "/blog", label: "Zurück zum Blog" }}
|
||||||
backgroundSymbol="B"
|
backgroundSymbol="B"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -83,14 +95,16 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{slug === 'first-note' && (
|
{slug === "first-note" && (
|
||||||
<>
|
<>
|
||||||
<LeadParagraph>
|
<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>
|
</LeadParagraph>
|
||||||
<H2>Why write in public?</H2>
|
<H2>Why write in public?</H2>
|
||||||
<Paragraph>
|
<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>
|
</Paragraph>
|
||||||
<H2>What to expect</H2>
|
<H2>What to expect</H2>
|
||||||
<UL>
|
<UL>
|
||||||
@@ -102,17 +116,20 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{slug === 'debugging-tips' && (
|
{slug === "debugging-tips" && (
|
||||||
<>
|
<>
|
||||||
<LeadParagraph>
|
<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>
|
</LeadParagraph>
|
||||||
<H2>Why print statements work</H2>
|
<H2>Why print statements work</H2>
|
||||||
<Paragraph>
|
<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>
|
</Paragraph>
|
||||||
<CodeBlock language="python" showLineNumbers={true}>
|
<CodeBlock language="python" showLineNumbers={true}>
|
||||||
{`def process_data(data):
|
{`def process_data(data):
|
||||||
print(f"Processing {len(data)} items")
|
print(f"Processing {len(data)} items")
|
||||||
result = expensive_operation(data)
|
result = expensive_operation(data)
|
||||||
print(f"Operation result: {result}")
|
print(f"Operation result: {result}")
|
||||||
@@ -121,7 +138,8 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
|
|||||||
|
|
||||||
<H2>Complete examples</H2>
|
<H2>Complete examples</H2>
|
||||||
<Paragraph>
|
<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>
|
</Paragraph>
|
||||||
|
|
||||||
<div className="my-8">
|
<div className="my-8">
|
||||||
@@ -130,29 +148,39 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{slug === 'architecture-patterns' && (
|
{slug === "architecture-patterns" && (
|
||||||
<>
|
<>
|
||||||
<LeadParagraph>
|
<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>
|
</LeadParagraph>
|
||||||
<H2>Repository Pattern</H2>
|
<H2>Repository Pattern</H2>
|
||||||
<Paragraph>
|
<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>
|
</Paragraph>
|
||||||
|
|
||||||
<H2>Service Layer</H2>
|
<H2>Service Layer</H2>
|
||||||
<Paragraph>
|
<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>
|
</Paragraph>
|
||||||
|
|
||||||
<H2>Domain Events</H2>
|
<H2>Domain Events</H2>
|
||||||
<Paragraph>
|
<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>
|
</Paragraph>
|
||||||
|
|
||||||
<H2>Complete examples</H2>
|
<H2>Complete examples</H2>
|
||||||
<Paragraph>
|
<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>
|
</Paragraph>
|
||||||
|
|
||||||
<div className="my-8">
|
<div className="my-8">
|
||||||
@@ -161,29 +189,38 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{slug === 'docker-deployment' && (
|
{slug === "docker-deployment" && (
|
||||||
<>
|
<>
|
||||||
<LeadParagraph>
|
<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>
|
</LeadParagraph>
|
||||||
<H2>Multi-stage builds</H2>
|
<H2>Multi-stage builds</H2>
|
||||||
<Paragraph>
|
<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>
|
</Paragraph>
|
||||||
|
|
||||||
<H2>Health checks and monitoring</H2>
|
<H2>Health checks and monitoring</H2>
|
||||||
<Paragraph>
|
<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>
|
</Paragraph>
|
||||||
|
|
||||||
<H2>Orchestration with Docker Compose</H2>
|
<H2>Orchestration with Docker Compose</H2>
|
||||||
<Paragraph>
|
<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>
|
</Paragraph>
|
||||||
|
|
||||||
<H2>Complete examples</H2>
|
<H2>Complete examples</H2>
|
||||||
<Paragraph>
|
<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>
|
</Paragraph>
|
||||||
|
|
||||||
<div className="my-8">
|
<div className="my-8">
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
import { motion, useScroll, useTransform } from "framer-motion";
|
||||||
import { Section } from '../../../src/components/Section';
|
import { Section } from "../../../src/components/Section";
|
||||||
import { Reveal } from '../../../src/components/Reveal';
|
import { Reveal } from "../../../src/components/Reveal";
|
||||||
import { H1, H2, H3, LeadText, Label, MonoLabel, BodyText } from '../../../src/components/Typography';
|
import {
|
||||||
import { BackgroundGrid, Container } from '../../../src/components/Layout';
|
H1,
|
||||||
import { MotionButton } from '../../../src/components/Button';
|
H2,
|
||||||
import { IframeSection } from '../../../src/components/IframeSection';
|
H3,
|
||||||
|
LeadText,
|
||||||
|
Label,
|
||||||
|
MonoLabel,
|
||||||
|
BodyText,
|
||||||
|
} from "../../../src/components/Typography";
|
||||||
|
import { BackgroundGrid, Container } from "../../../src/components/Layout";
|
||||||
|
import { MotionButton } from "../../../src/components/Button";
|
||||||
|
import { IframeSection } from "../../../src/components/IframeSection";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Database,
|
|
||||||
Layout,
|
|
||||||
Users,
|
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Zap,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Globe2,
|
|
||||||
Settings,
|
|
||||||
Search,
|
|
||||||
Monitor,
|
|
||||||
Cpu,
|
Cpu,
|
||||||
Server,
|
Server,
|
||||||
Layers
|
Layers,
|
||||||
} from 'lucide-react';
|
} from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TECHNICAL MARKER COMPONENT
|
* TECHNICAL MARKER COMPONENT
|
||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
*/
|
*/
|
||||||
const Marker: React.FC<{ children: React.ReactNode; delay?: number }> = ({
|
const Marker: React.FC<{ children: React.ReactNode; delay?: number }> = ({
|
||||||
children,
|
children,
|
||||||
delay = 0
|
delay = 0,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<span className="relative inline-block px-1">
|
<span className="relative inline-block px-1">
|
||||||
@@ -40,7 +40,11 @@ const Marker: React.FC<{ children: React.ReactNode; delay?: number }> = ({
|
|||||||
initial={{ scaleX: 0, opacity: 0 }}
|
initial={{ scaleX: 0, opacity: 0 }}
|
||||||
whileInView={{ scaleX: 1, opacity: 1 }}
|
whileInView={{ scaleX: 1, opacity: 1 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 1.2, delay: delay + 0.1, ease: [0.23, 1, 0.32, 1] }}
|
transition={{
|
||||||
|
duration: 1.2,
|
||||||
|
delay: delay + 0.1,
|
||||||
|
ease: [0.23, 1, 0.32, 1],
|
||||||
|
}}
|
||||||
className="absolute inset-0 z-[-1] -skew-x-6 rotate-[-1deg] translate-y-1 transform-gpu bg-[rgba(255,235,59,0.95)] origin-left"
|
className="absolute inset-0 z-[-1] -skew-x-6 rotate-[-1deg] translate-y-1 transform-gpu bg-[rgba(255,235,59,0.95)] origin-left"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
@@ -57,7 +61,10 @@ export default function KLZCablesCaseStudy() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col bg-white relative min-h-screen selection:bg-slate-900 selection:text-white overflow-hidden">
|
<div className="flex flex-col bg-white relative min-h-screen selection:bg-slate-900 selection:text-white overflow-hidden">
|
||||||
<motion.div style={{ opacity: heroOpacity }} className="fixed inset-0 z-0 pointer-events-none">
|
<motion.div
|
||||||
|
style={{ opacity: heroOpacity }}
|
||||||
|
className="fixed inset-0 z-0 pointer-events-none"
|
||||||
|
>
|
||||||
<BackgroundGrid />
|
<BackgroundGrid />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -79,8 +86,12 @@ export default function KLZCablesCaseStudy() {
|
|||||||
className="h-px bg-slate-900"
|
className="h-px bg-slate-900"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<MonoLabel className="text-slate-900 tracking-[0.4em]">SYSTEM-ARCHITEKTUR // 2025</MonoLabel>
|
<MonoLabel className="text-slate-900 tracking-[0.4em]">
|
||||||
<Label className="text-[10px] text-slate-400 font-mono">HARDENED WORDPRESS // VARNISH STACK</Label>
|
SYSTEM-ARCHITEKTUR // 2025
|
||||||
|
</MonoLabel>
|
||||||
|
<Label className="text-[10px] text-slate-400 font-mono">
|
||||||
|
HARDENED WORDPRESS // VARNISH STACK
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -88,7 +99,8 @@ export default function KLZCablesCaseStudy() {
|
|||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
<Reveal delay={0.1} direction="up" scale={0.98} blur>
|
<Reveal delay={0.1} direction="up" scale={0.98} blur>
|
||||||
<H1 className="text-6xl md:text-8xl tracking-tighter leading-[0.9] font-bold text-slate-900">
|
<H1 className="text-6xl md:text-8xl tracking-tighter leading-[0.9] font-bold text-slate-900">
|
||||||
KLZ Cables<br />
|
KLZ Cables
|
||||||
|
<br />
|
||||||
<span className="text-slate-100">Case Study.</span>
|
<span className="text-slate-100">Case Study.</span>
|
||||||
</H1>
|
</H1>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -100,7 +112,10 @@ export default function KLZCablesCaseStudy() {
|
|||||||
<Marker delay={0.2}>B2B Commerce Systems.</Marker>
|
<Marker delay={0.2}>B2B Commerce Systems.</Marker>
|
||||||
</LeadText>
|
</LeadText>
|
||||||
<BodyText className="mt-6 text-lg md:text-xl text-slate-500 max-w-xl leading-relaxed font-serif italic">
|
<BodyText className="mt-6 text-lg md:text-xl text-slate-500 max-w-xl leading-relaxed font-serif italic">
|
||||||
Vom statischen Altsystem zum industriellen Standard. Ich habe das KLZ-System auf das Wesentliche reduziert: Hardened Infrastructure, parametrische Datenpflege und zero maintenance.
|
Vom statischen Altsystem zum industriellen Standard. Ich
|
||||||
|
habe das KLZ-System auf das Wesentliche reduziert: Hardened
|
||||||
|
Infrastructure, parametrische Datenpflege und zero
|
||||||
|
maintenance.
|
||||||
</BodyText>
|
</BodyText>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -112,7 +127,9 @@ export default function KLZCablesCaseStudy() {
|
|||||||
<Label className="text-slate-400">Data Integrity</Label>
|
<Label className="text-slate-400">Data Integrity</Label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-2.5 h-2.5 bg-[rgba(129,199,132,1)] rounded-full animate-pulse" />
|
<div className="w-2.5 h-2.5 bg-[rgba(129,199,132,1)] rounded-full animate-pulse" />
|
||||||
<span className="text-2xl font-bold font-mono text-slate-900 tracking-tight">Relational Data</span>
|
<span className="text-2xl font-bold font-mono text-slate-900 tracking-tight">
|
||||||
|
Relational Data
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -139,7 +156,8 @@ export default function KLZCablesCaseStudy() {
|
|||||||
<div className="md:col-span-12 mb-12">
|
<div className="md:col-span-12 mb-12">
|
||||||
<Reveal direction="left" blur>
|
<Reveal direction="left" blur>
|
||||||
<H2 className="text-5xl md:text-8xl tracking-tighter mb-12">
|
<H2 className="text-5xl md:text-8xl tracking-tighter mb-12">
|
||||||
Architektur- <br />Refactor.
|
Architektur- <br />
|
||||||
|
Refactor.
|
||||||
</H2>
|
</H2>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +168,14 @@ export default function KLZCablesCaseStudy() {
|
|||||||
Vom statischen HTML zur zentralen Daten-Instanz.
|
Vom statischen HTML zur zentralen Daten-Instanz.
|
||||||
</BodyText>
|
</BodyText>
|
||||||
<BodyText className="text-xl text-slate-600 leading-relaxed">
|
<BodyText className="text-xl text-slate-600 leading-relaxed">
|
||||||
Ich habe die KLZ-Architektur radikal auf einen entkoppelten High-Performance-Stack umgestellt. WordPress fungiert hier nicht als CMS-Baukasten, sondern als <Marker delay={0.3}>Headless JSON-Provider</Marker>. Durch die Implementierung nativer PHP-Microservices und den Verzicht auf volatile Drittanbieter-Plugins wurde ein System geschaffen, das keine technologischen Überraschungen zulässt. <Marker delay={0.5}>Stability by Design.</Marker>
|
Ich habe die KLZ-Architektur radikal auf einen entkoppelten
|
||||||
|
High-Performance-Stack umgestellt. WordPress fungiert hier
|
||||||
|
nicht als CMS-Baukasten, sondern als{" "}
|
||||||
|
<Marker delay={0.3}>Headless JSON-Provider</Marker>. Durch die
|
||||||
|
Implementierung nativer PHP-Microservices und den Verzicht auf
|
||||||
|
volatile Drittanbieter-Plugins wurde ein System geschaffen,
|
||||||
|
das keine technologischen Überraschungen zulässt.{" "}
|
||||||
|
<Marker delay={0.5}>Stability by Design.</Marker>
|
||||||
</BodyText>
|
</BodyText>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -166,21 +191,37 @@ export default function KLZCablesCaseStudy() {
|
|||||||
<Label className="text-slate-900">System Metriken</Label>
|
<Label className="text-slate-900">System Metriken</Label>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{[
|
{[
|
||||||
{ label: 'Edge Caching', desc: 'Varnish + W3TC Object Cache', icon: <Server className="w-5 h-5 text-slate-400" /> },
|
{
|
||||||
{ label: 'Analytics', desc: 'Independent (Global Data Compliance)', icon: <Activity className="w-5 h-5 text-slate-400" /> },
|
label: "Edge Caching",
|
||||||
{ label: 'Custom Core', desc: 'REST via Native Services', icon: <Cpu className="w-5 h-5 text-slate-400" /> }
|
desc: "Varnish + W3TC Object Cache",
|
||||||
|
icon: <Server className="w-5 h-5 text-slate-400" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Analytics",
|
||||||
|
desc: "Independent (Global Data Compliance)",
|
||||||
|
icon: <Activity className="w-5 h-5 text-slate-400" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Custom Core",
|
||||||
|
desc: "REST via Native Services",
|
||||||
|
icon: <Cpu className="w-5 h-5 text-slate-400" />,
|
||||||
|
},
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={i}
|
key={i}
|
||||||
initial={{ x: -20, opacity: 0 }}
|
initial={{ x: -20, opacity: 0 }}
|
||||||
whileInView={{ x: 0, opacity: 1 }}
|
whileInView={{ x: 0, opacity: 1 }}
|
||||||
transition={{ delay: 0.5 + (i * 0.1), duration: 0.5 }}
|
transition={{ delay: 0.5 + i * 0.1, duration: 0.5 }}
|
||||||
className="flex gap-6 border-b border-slate-200/50 pb-6 last:border-0 last:pb-0"
|
className="flex gap-6 border-b border-slate-200/50 pb-6 last:border-0 last:pb-0"
|
||||||
>
|
>
|
||||||
<div className="shrink-0 mt-1">{item.icon}</div>
|
<div className="shrink-0 mt-1">{item.icon}</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<MonoLabel className="text-[10px] text-slate-400">{item.label}</MonoLabel>
|
<MonoLabel className="text-[10px] text-slate-400">
|
||||||
<BodyText className="text-base font-bold text-slate-900">{item.desc}</BodyText>
|
{item.label}
|
||||||
|
</MonoLabel>
|
||||||
|
<BodyText className="text-base font-bold text-slate-900">
|
||||||
|
{item.desc}
|
||||||
|
</BodyText>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
@@ -198,8 +239,12 @@ export default function KLZCablesCaseStudy() {
|
|||||||
<Reveal direction="none" blur>
|
<Reveal direction="none" blur>
|
||||||
<div className="relative mb-16 flex justify-between items-end">
|
<div className="relative mb-16 flex justify-between items-end">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Label className="text-slate-500">Infrastructure Validation</Label>
|
<Label className="text-slate-500">
|
||||||
<H3 className="text-5xl md:text-8xl tracking-tighter">Global Hub.</H3>
|
Infrastructure Validation
|
||||||
|
</Label>
|
||||||
|
<H3 className="text-5xl md:text-8xl tracking-tighter">
|
||||||
|
Global Hub.
|
||||||
|
</H3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -234,12 +279,17 @@ export default function KLZCablesCaseStudy() {
|
|||||||
<Reveal direction="left" blur>
|
<Reveal direction="left" blur>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Label className="text-slate-400">Asset Pipelines</Label>
|
<Label className="text-slate-400">Asset Pipelines</Label>
|
||||||
<H3 className="text-4xl md:text-6xl tracking-tighter">Automated Documentation.</H3>
|
<H3 className="text-4xl md:text-6xl tracking-tighter">
|
||||||
|
Automated Documentation.
|
||||||
|
</H3>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={0.1} direction="right" blur>
|
<Reveal delay={0.1} direction="right" blur>
|
||||||
<BodyText className="text-xl text-slate-500 pb-2 font-serif italic">
|
<BodyText className="text-xl text-slate-500 pb-2 font-serif italic">
|
||||||
Für Hochspannungs-N2XS(F)2Y Kabel ist Datentreue eine Sicherheitsanforderung. Ich habe eine automatisierte Asset-Pipeline entwickelt, die technische Datenblätter serverseitig generiert und validiert.
|
Für Hochspannungs-N2XS(F)2Y Kabel ist Datentreue eine
|
||||||
|
Sicherheitsanforderung. Ich habe eine automatisierte
|
||||||
|
Asset-Pipeline entwickelt, die technische Datenblätter
|
||||||
|
serverseitig generiert und validiert.
|
||||||
</BodyText>
|
</BodyText>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +327,8 @@ export default function KLZCablesCaseStudy() {
|
|||||||
<div className="lg:col-span-12 mb-12 text-center lg:text-left relative z-10">
|
<div className="lg:col-span-12 mb-12 text-center lg:text-left relative z-10">
|
||||||
<Reveal direction="down" blur>
|
<Reveal direction="down" blur>
|
||||||
<H3 className="text-4xl md:text-6xl max-w-4xl tracking-tighter">
|
<H3 className="text-4xl md:text-6xl max-w-4xl tracking-tighter">
|
||||||
Fokus auf <br /><Marker delay={0.2}>Spezifikationen.</Marker>
|
Fokus auf <br />
|
||||||
|
<Marker delay={0.2}>Spezifikationen.</Marker>
|
||||||
</H3>
|
</H3>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,14 +357,19 @@ export default function KLZCablesCaseStudy() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Label className="text-slate-400">Katalog-Struktur</Label>
|
<Label className="text-slate-400">Katalog-Struktur</Label>
|
||||||
<LeadText className="text-lg">
|
<LeadText className="text-lg">
|
||||||
Der Produktbereich wurde konsequent auf die Bedürfnisse technischer Planer optimiert. Klare Hierarchien und der Verzicht auf E-Commerce-Rauschen ermöglichen einen direkten Zugriff auf Kabel-Parameter und Datenblätter.
|
Der Produktbereich wurde konsequent auf die Bedürfnisse
|
||||||
|
technischer Planer optimiert. Klare Hierarchien und der
|
||||||
|
Verzicht auf E-Commerce-Rauschen ermöglichen einen direkten
|
||||||
|
Zugriff auf Kabel-Parameter und Datenblätter.
|
||||||
</LeadText>
|
</LeadText>
|
||||||
<motion.div
|
<motion.div
|
||||||
whileHover={{ x: 10 }}
|
whileHover={{ x: 10 }}
|
||||||
className="p-8 bg-white border border-slate-200 rounded-3xl shadow-sm"
|
className="p-8 bg-white border border-slate-200 rounded-3xl shadow-sm"
|
||||||
>
|
>
|
||||||
<Layers className="w-6 h-6 text-slate-400 mb-4" />
|
<Layers className="w-6 h-6 text-slate-400 mb-4" />
|
||||||
<BodyText className="text-sm font-medium">Strukturierte Aufbereitung technischer Produktdaten.</BodyText>
|
<BodyText className="text-sm font-medium">
|
||||||
|
Strukturierte Aufbereitung technischer Produktdaten.
|
||||||
|
</BodyText>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -334,9 +390,14 @@ export default function KLZCablesCaseStudy() {
|
|||||||
<Reveal direction="left" blur>
|
<Reveal direction="left" blur>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Label className="text-slate-400">Knowledge Transfer</Label>
|
<Label className="text-slate-400">Knowledge Transfer</Label>
|
||||||
<H3 className="text-4xl md:text-6xl tracking-tighter">Insights & News.</H3>
|
<H3 className="text-4xl md:text-6xl tracking-tighter">
|
||||||
|
Insights & News.
|
||||||
|
</H3>
|
||||||
<BodyText className="text-xl text-slate-500 font-serif italic">
|
<BodyText className="text-xl text-slate-500 font-serif italic">
|
||||||
Die News-Engine dient als technischer Hub für Industrie-Standards. Durch die Implementierung eines performanten Blog-Systems wird Fachwissen direkt an die Zielgruppe kommuniziert.
|
Die News-Engine dient als technischer Hub für
|
||||||
|
Industrie-Standards. Durch die Implementierung eines
|
||||||
|
performanten Blog-Systems wird Fachwissen direkt an die
|
||||||
|
Zielgruppe kommuniziert.
|
||||||
</BodyText>
|
</BodyText>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -371,9 +432,15 @@ export default function KLZCablesCaseStudy() {
|
|||||||
>
|
>
|
||||||
<div className="space-y-16 text-center">
|
<div className="space-y-16 text-center">
|
||||||
<Reveal direction="up" blur>
|
<Reveal direction="up" blur>
|
||||||
<H3 className="text-5xl md:text-8xl tracking-tighter">System-Lifecycle.</H3>
|
<H3 className="text-5xl md:text-8xl tracking-tighter">
|
||||||
|
System-Lifecycle.
|
||||||
|
</H3>
|
||||||
<LeadText className="mx-auto max-w-2xl pt-6 text-xl">
|
<LeadText className="mx-auto max-w-2xl pt-6 text-xl">
|
||||||
Die Migration von einer statischen Datei-Struktur zu einer zentralisierten Daten-Instanz eliminiert technische Schulden und manuelle Fehlerquellen. Das Ergebnis ist eine wartungsfreie Architektur, die technische Datentreue über den gesamten Produkt-Lifecycle sicherstellt.
|
Die Migration von einer statischen Datei-Struktur zu einer
|
||||||
|
zentralisierten Daten-Instanz eliminiert technische Schulden und
|
||||||
|
manuelle Fehlerquellen. Das Ergebnis ist eine wartungsfreie
|
||||||
|
Architektur, die technische Datentreue über den gesamten
|
||||||
|
Produkt-Lifecycle sicherstellt.
|
||||||
</LeadText>
|
</LeadText>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
@@ -424,9 +491,14 @@ export default function KLZCablesCaseStudy() {
|
|||||||
<Reveal direction="right" blur>
|
<Reveal direction="right" blur>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Label className="text-slate-400">Conversion Layer</Label>
|
<Label className="text-slate-400">Conversion Layer</Label>
|
||||||
<H3 className="text-4xl md:text-6xl tracking-tighter">Direkter Draht.</H3>
|
<H3 className="text-4xl md:text-6xl tracking-tighter">
|
||||||
|
Direkter Draht.
|
||||||
|
</H3>
|
||||||
<BodyText className="text-xl text-slate-500 font-serif italic">
|
<BodyText className="text-xl text-slate-500 font-serif italic">
|
||||||
Das Kontakt-System wurde auf maximale Reduktion getrimmt. Keine unnötigen Hürden, sondern ein direkter Kommunikations-Kanal zwischen technischem Bedarf und individueller Beratung.
|
Das Kontakt-System wurde auf maximale Reduktion getrimmt.
|
||||||
|
Keine unnötigen Hürden, sondern ein direkter
|
||||||
|
Kommunikations-Kanal zwischen technischem Bedarf und
|
||||||
|
individueller Beratung.
|
||||||
</BodyText>
|
</BodyText>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -442,7 +514,9 @@ export default function KLZCablesCaseStudy() {
|
|||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
<Reveal direction="left" blur>
|
<Reveal direction="left" blur>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<MonoLabel className="text-slate-400 tracking-[0.4em]">CONSULTING // ENGINEERING</MonoLabel>
|
<MonoLabel className="text-slate-400 tracking-[0.4em]">
|
||||||
|
CONSULTING // ENGINEERING
|
||||||
|
</MonoLabel>
|
||||||
<H2 className="text-6xl md:text-8xl tracking-tighter leading-none font-bold">
|
<H2 className="text-6xl md:text-8xl tracking-tighter leading-none font-bold">
|
||||||
Architektur <br />
|
Architektur <br />
|
||||||
<span className="text-slate-100">ohne Altlasten.</span>
|
<span className="text-slate-100">ohne Altlasten.</span>
|
||||||
@@ -451,7 +525,10 @@ export default function KLZCablesCaseStudy() {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={0.2} direction="left" blur>
|
<Reveal delay={0.2} direction="left" blur>
|
||||||
<BodyText className="text-2xl text-slate-500 max-w-xl font-serif italic leading-relaxed">
|
<BodyText className="text-2xl text-slate-500 max-w-xl font-serif italic leading-relaxed">
|
||||||
Vom Prototyp zum industriellen Standard. Ich entwickle digitale Infrastrukturen, die technische Freiheit und operative Stabilität garantieren – wartungsfrei und skalierbar.
|
Vom Prototyp zum industriellen Standard. Ich entwickle
|
||||||
|
digitale Infrastrukturen, die technische Freiheit und
|
||||||
|
operative Stabilität garantieren – wartungsfrei und
|
||||||
|
skalierbar.
|
||||||
</BodyText>
|
</BodyText>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
@@ -464,19 +541,36 @@ export default function KLZCablesCaseStudy() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{[
|
{[
|
||||||
{ title: "Hardened Infrastructure", desc: "Zentralisierte Datenpflege und entkoppelte WordPress-Instanzen." },
|
{
|
||||||
{ title: "Automated Data Pipelines", desc: "Validierung technischer Spezifikationen ohne manuelle Eingriffe." },
|
title: "Hardened Infrastructure",
|
||||||
{ title: "Maintenance-Free Core", desc: "Plugin-freie Logik für deterministische System-Sicherheit." }
|
desc: "Zentralisierte Datenpflege und entkoppelte WordPress-Instanzen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Automated Data Pipelines",
|
||||||
|
desc: "Validierung technischer Spezifikationen ohne manuelle Eingriffe.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Maintenance-Free Core",
|
||||||
|
desc: "Plugin-freie Logik für deterministische System-Sicherheit.",
|
||||||
|
},
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<div key={i} className="space-y-3 group/item">
|
<div key={i} className="space-y-3 group/item">
|
||||||
<MonoLabel className="text-[10px] text-slate-400 group-hover/item:text-slate-900 transition-colors duration-500">{item.title}</MonoLabel>
|
<MonoLabel className="text-[10px] text-slate-400 group-hover/item:text-slate-900 transition-colors duration-500">
|
||||||
<BodyText className="text-lg font-bold text-slate-900 leading-tight">{item.desc}</BodyText>
|
{item.title}
|
||||||
|
</MonoLabel>
|
||||||
|
<BodyText className="text-lg font-bold text-slate-900 leading-tight">
|
||||||
|
{item.desc}
|
||||||
|
</BodyText>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={0.5} direction="up" blur className="pt-6">
|
<Reveal delay={0.5} direction="up" blur className="pt-6">
|
||||||
<MotionButton href="/contact" variant="outline" className="w-full py-8 text-lg group border-2 border-slate-900 rounded-full bg-white hover:bg-slate-900 hover:text-white transition-all duration-700">
|
<MotionButton
|
||||||
|
href="/contact"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full py-8 text-lg group border-2 border-slate-900 rounded-full bg-white hover:bg-slate-900 hover:text-white transition-all duration-700"
|
||||||
|
>
|
||||||
System-Analyse anfragen
|
System-Analyse anfragen
|
||||||
<ArrowRight className="inline-block ml-4 w-6 h-6 group-hover:translate-x-4 transition-transform duration-700" />
|
<ArrowRight className="inline-block ml-4 w-6 h-6 group-hover:translate-x-4 transition-transform duration-700" />
|
||||||
</MotionButton>
|
</MotionButton>
|
||||||
@@ -488,4 +582,3 @@ export default function KLZCablesCaseStudy() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { PageHeader } from '../../src/components/PageHeader';
|
import { PageHeader } from "../../src/components/PageHeader";
|
||||||
import { Section } from '../../src/components/Section';
|
import { Section } from "../../src/components/Section";
|
||||||
import { Reveal } from '../../src/components/Reveal';
|
import { Reveal } from "../../src/components/Reveal";
|
||||||
import { H3, LeadText, Label } from '../../src/components/Typography';
|
import { H3, LeadText, Label } from "../../src/components/Typography";
|
||||||
import { BackgroundGrid, Card, Container } from '../../src/components/Layout';
|
import { BackgroundGrid, Card } from "../../src/components/Layout";
|
||||||
import { MotionButton } from '../../src/components/Button';
|
import { MotionButton } from "../../src/components/Button";
|
||||||
import Image from 'next/image';
|
import Image from "next/image";
|
||||||
|
|
||||||
export default function CaseStudiesPage() {
|
export default function CaseStudiesPage() {
|
||||||
return (
|
return (
|
||||||
@@ -15,9 +15,14 @@ export default function CaseStudiesPage() {
|
|||||||
<BackgroundGrid />
|
<BackgroundGrid />
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={<>Case Studies: <br /><span className="text-slate-200">Qualität in jedem Detail.</span></>}
|
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."
|
description="Ein Blick hinter die Kulissen ausgewählter Projekte. Von der ersten Idee bis zum fertigen Hochleistungssystem."
|
||||||
backLink={{ href: '/', label: 'Zurück' }}
|
backLink={{ href: "/", label: "Zurück" }}
|
||||||
backgroundSymbol="C"
|
backgroundSymbol="C"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -42,9 +47,13 @@ export default function CaseStudiesPage() {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Label>Infrastructure & Energy</Label>
|
<Label>Infrastructure & Energy</Label>
|
||||||
<H3 className="group-hover:text-slate-900 transition-colors">KLZ Cables – Digitaler Netzbau</H3>
|
<H3 className="group-hover:text-slate-900 transition-colors">
|
||||||
|
KLZ Cables – Digitaler Netzbau
|
||||||
|
</H3>
|
||||||
<LeadText className="text-base line-clamp-3">
|
<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.
|
Wie wir eine komplexe WordPress-Struktur in ein performantes,
|
||||||
|
sauberes und langlebiges Web-System verwandelt haben. Fokus
|
||||||
|
auf Performance, SEO und Benutzerführung.
|
||||||
</LeadText>
|
</LeadText>
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
@@ -59,9 +68,12 @@ export default function CaseStudiesPage() {
|
|||||||
<Reveal delay={0.2}>
|
<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">
|
<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>
|
<Label>Demnächst</Label>
|
||||||
<H3 className="text-slate-200">Weitere Projekte sind in Arbeit.</H3>
|
<H3 className="text-slate-200">
|
||||||
|
Weitere Projekte sind in Arbeit.
|
||||||
|
</H3>
|
||||||
<LeadText className="text-base italic">
|
<LeadText className="text-base italic">
|
||||||
Ich dokumentiere gerade weitere spannende Projekte aus den Bereichen SaaS, E-Commerce und Systemarchitektur.
|
Ich dokumentiere gerade weitere spannende Projekte aus den
|
||||||
|
Bereichen SaaS, E-Commerce und Systemarchitektur.
|
||||||
</LeadText>
|
</LeadText>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -73,12 +85,17 @@ export default function CaseStudiesPage() {
|
|||||||
<Reveal>
|
<Reveal>
|
||||||
<H3 className="text-4xl leading-tight">
|
<H3 className="text-4xl leading-tight">
|
||||||
Warum ich Case Studies zeige? <br />
|
Warum ich Case Studies zeige? <br />
|
||||||
<span className="text-slate-200">Weil Code mehr als Text ist.</span>
|
<span className="text-slate-200">
|
||||||
|
Weil Code mehr als Text ist.
|
||||||
|
</span>
|
||||||
</H3>
|
</H3>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={0.2}>
|
<Reveal delay={0.2}>
|
||||||
<LeadText className="text-xl">
|
<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.
|
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>
|
</LeadText>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CheerioCrawler, RequestQueue } from "crawlee";
|
import { CheerioCrawler } from "crawlee";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
@@ -1055,7 +1055,7 @@ ${JSON.stringify({ facts, strategy, ia, positionsData }, null, 2)}
|
|||||||
finalState.sitemap = finalState.sitemap.sitemap;
|
finalState.sitemap = finalState.sitemap.sitemap;
|
||||||
else {
|
else {
|
||||||
const entries = Object.entries(finalState.sitemap);
|
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]) => ({
|
finalState.sitemap = entries.map(([category, pages]) => ({
|
||||||
category,
|
category,
|
||||||
pages,
|
pages,
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import { chromium, type Page } from 'playwright';
|
import { chromium } from "playwright";
|
||||||
import path from 'node:path';
|
import path from "node:path";
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from "node:url";
|
||||||
import fs from 'node:fs';
|
import fs from "node:fs";
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
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) {
|
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) {
|
async function downloadFile(url: string, assetsDir: string) {
|
||||||
if (url.startsWith('//')) url = `https:${url}`;
|
if (url.startsWith("//")) url = `https:${url}`;
|
||||||
if (!url.startsWith('http')) return null;
|
if (!url.startsWith("http")) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
@@ -25,15 +29,16 @@ async function downloadFile(url: string, assetsDir: string) {
|
|||||||
if (fs.existsSync(dest)) return `./assets/${relPath}`;
|
if (fs.existsSync(dest)) return `./assets/${relPath}`;
|
||||||
|
|
||||||
const res = await axios.get(url, {
|
const res = await axios.get(url, {
|
||||||
responseType: 'arraybuffer',
|
responseType: "arraybuffer",
|
||||||
headers: { 'User-Agent': USER_AGENT },
|
headers: { "User-Agent": USER_AGENT },
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
validateStatus: () => true
|
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 });
|
if (!fs.existsSync(path.dirname(dest)))
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
fs.writeFileSync(dest, Buffer.from(res.data));
|
fs.writeFileSync(dest, Buffer.from(res.data));
|
||||||
return `./assets/${relPath}`;
|
return `./assets/${relPath}`;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -41,7 +46,13 @@ async function downloadFile(url: string, assetsDir: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processCssRecursively(cssContent: string, cssUrl: string, assetsDir: string, urlMap: Record<string, string>, depth = 0) {
|
async function processCssRecursively(
|
||||||
|
cssContent: string,
|
||||||
|
cssUrl: string,
|
||||||
|
assetsDir: string,
|
||||||
|
urlMap: Record<string, string>,
|
||||||
|
depth = 0,
|
||||||
|
) {
|
||||||
if (depth > 5) return cssContent;
|
if (depth > 5) return cssContent;
|
||||||
|
|
||||||
// Capture both standard url(...) and @import url(...)
|
// Capture both standard url(...) and @import url(...)
|
||||||
@@ -51,7 +62,8 @@ async function processCssRecursively(cssContent: string, cssUrl: string, assetsD
|
|||||||
|
|
||||||
while ((match = urlRegex.exec(cssContent)) !== null) {
|
while ((match = urlRegex.exec(cssContent)) !== null) {
|
||||||
const originalUrl = match[1];
|
const originalUrl = match[1];
|
||||||
if (originalUrl.startsWith('data:') || originalUrl.startsWith('blob:')) continue;
|
if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
|
||||||
|
continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const absUrl = new URL(originalUrl, cssUrl).href;
|
const absUrl = new URL(originalUrl, cssUrl).href;
|
||||||
@@ -64,13 +76,18 @@ async function processCssRecursively(cssContent: string, cssUrl: string, assetsD
|
|||||||
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
|
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
|
||||||
|
|
||||||
// We need to route from the folder containing the CSS to the asset
|
// We need to route from the folder containing the CSS to the asset
|
||||||
const rel = path.relative(path.dirname(sanitizePath(cssPath)), sanitizePath(assetPath));
|
const rel = path.relative(
|
||||||
|
path.dirname(sanitizePath(cssPath)),
|
||||||
|
sanitizePath(assetPath),
|
||||||
|
);
|
||||||
|
|
||||||
// Replace strictly the URL part
|
// Replace strictly the URL part
|
||||||
newContent = newContent.split(originalUrl).join(rel);
|
newContent = newContent.split(originalUrl).join(rel);
|
||||||
urlMap[absUrl] = local;
|
urlMap[absUrl] = local;
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {
|
||||||
|
// Ignore URL resolution errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return newContent;
|
return newContent;
|
||||||
}
|
}
|
||||||
@@ -78,48 +95,57 @@ async function processCssRecursively(cssContent: string, cssUrl: string, assetsD
|
|||||||
async function run() {
|
async function run() {
|
||||||
const rawUrl = process.argv[2];
|
const rawUrl = process.argv[2];
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
console.error('Usage: npm run clone-page <url>');
|
console.error("Usage: npm run clone-page <url>");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const targetUrl = rawUrl.trim();
|
const targetUrl = rawUrl.trim();
|
||||||
const urlObj = new URL(targetUrl);
|
const urlObj = new URL(targetUrl);
|
||||||
|
|
||||||
// Setup Output Directories
|
// Setup Output Directories
|
||||||
const domainSlug = urlObj.hostname.replace('www.', '');
|
const domainSlug = urlObj.hostname.replace("www.", "");
|
||||||
const domainDir = path.resolve(__dirname, `../public/showcase/${domainSlug}`);
|
const domainDir = path.resolve(__dirname, `../public/showcase/${domainSlug}`);
|
||||||
const assetsDir = path.join(domainDir, 'assets');
|
const assetsDir = path.join(domainDir, "assets");
|
||||||
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
|
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
|
||||||
|
|
||||||
let pageSlug = urlObj.pathname.split('/').filter(Boolean).join('-');
|
let pageSlug = urlObj.pathname.split("/").filter(Boolean).join("-");
|
||||||
if (!pageSlug) pageSlug = 'index';
|
if (!pageSlug) pageSlug = "index";
|
||||||
const htmlFilename = `${pageSlug}.html`;
|
const htmlFilename = `${pageSlug}.html`;
|
||||||
|
|
||||||
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
|
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
|
||||||
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
const browser = await chromium.launch({ headless: true });
|
||||||
// Start with a standard viewport, we will resize widely later
|
// Start with a standard viewport, we will resize widely later
|
||||||
const context = await browser.newContext({ userAgent: USER_AGENT, viewport: { width: 1920, height: 1080 } });
|
const context = await browser.newContext({
|
||||||
|
userAgent: USER_AGENT,
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
});
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
const urlMap: Record<string, string> = {};
|
const urlMap: Record<string, string> = {};
|
||||||
const foundAssets = new Set<string>();
|
const foundAssets = new Set<string>();
|
||||||
|
|
||||||
// 1. Live Network Interception
|
// 1. Live Network Interception
|
||||||
page.on('response', response => {
|
page.on("response", (response) => {
|
||||||
const url = response.url();
|
const url = response.url();
|
||||||
if (response.status() === 200) {
|
if (response.status() === 200) {
|
||||||
// Capture anything that looks like a static asset
|
// 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)) {
|
if (
|
||||||
|
url.match(
|
||||||
|
/\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i,
|
||||||
|
)
|
||||||
|
) {
|
||||||
foundAssets.add(url);
|
foundAssets.add(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('🌐 Loading page (Waiting for Network Idle)...');
|
console.log("🌐 Loading page (Waiting for Network Idle)...");
|
||||||
await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 90000 });
|
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 90000 });
|
||||||
|
|
||||||
console.log('🌊 Executing "Scroll Wave" to trigger all lazy loaders naturally...');
|
console.log(
|
||||||
|
'🌊 Executing "Scroll Wave" to trigger all lazy loaders naturally...',
|
||||||
|
);
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
let totalHeight = 0;
|
let totalHeight = 0;
|
||||||
@@ -138,29 +164,37 @@ async function run() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('📐 Expanding Viewport to "Giant Mode" for final asset capture...');
|
console.log(
|
||||||
|
'📐 Expanding Viewport to "Giant Mode" for final asset capture...',
|
||||||
|
);
|
||||||
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
|
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||||
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
|
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
|
||||||
|
|
||||||
// Final settlement wait
|
// Final settlement wait
|
||||||
await page.waitForTimeout(3000);
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
console.log('💧 Final DOM Hydration & Sanitization...');
|
console.log("💧 Final DOM Hydration & Sanitization...");
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
// A. Deterministic Attribute Hydration (Generic)
|
// A. Deterministic Attribute Hydration (Generic)
|
||||||
// Scours every element for attributes that look like asset URLs and promotes them
|
// 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;
|
const assetPattern =
|
||||||
|
/\.(jpg|jpeg|png|gif|svg|webp|mp4|webm|woff2?|ttf|otf)/i;
|
||||||
|
|
||||||
document.querySelectorAll('*').forEach(el => {
|
document.querySelectorAll("*").forEach((el) => {
|
||||||
// 0. Skip Meta/Head/Script/Style/SVG tags for attribute promotion
|
// 0. Skip Meta/Head/Script/Style/SVG tags for attribute promotion
|
||||||
if (['META', 'LINK', 'HEAD', 'SCRIPT', 'STYLE', 'SVG', 'PATH'].includes(el.tagName)) return;
|
if (
|
||||||
|
["META", "LINK", "HEAD", "SCRIPT", "STYLE", "SVG", "PATH"].includes(
|
||||||
|
el.tagName,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
// 1. Force Visibility (Anti-Flicker)
|
// 1. Force Visibility (Anti-Flicker)
|
||||||
const htmlEl = el as HTMLElement;
|
const htmlEl = el as HTMLElement;
|
||||||
const style = window.getComputedStyle(htmlEl);
|
const style = window.getComputedStyle(htmlEl);
|
||||||
if (style.opacity === '0' || style.visibility === 'hidden') {
|
if (style.opacity === "0" || style.visibility === "hidden") {
|
||||||
htmlEl.style.setProperty('opacity', '1', 'important');
|
htmlEl.style.setProperty("opacity", "1", "important");
|
||||||
htmlEl.style.setProperty('visibility', 'visible', 'important');
|
htmlEl.style.setProperty("visibility", "visible", "important");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Promote Data Attributes
|
// 2. Promote Data Attributes
|
||||||
@@ -168,26 +202,30 @@ async function run() {
|
|||||||
const name = attr.name.toLowerCase();
|
const name = attr.name.toLowerCase();
|
||||||
const val = attr.value;
|
const val = attr.value;
|
||||||
|
|
||||||
if (assetPattern.test(val) || name.includes('src') || name.includes('image')) {
|
if (
|
||||||
|
assetPattern.test(val) ||
|
||||||
|
name.includes("src") ||
|
||||||
|
name.includes("image")
|
||||||
|
) {
|
||||||
// Standard Image/Video/Source promotion
|
// Standard Image/Video/Source promotion
|
||||||
if (el.tagName === 'IMG') {
|
if (el.tagName === "IMG") {
|
||||||
const img = el as HTMLImageElement;
|
const img = el as HTMLImageElement;
|
||||||
if (name.includes('srcset')) img.srcset = val;
|
if (name.includes("srcset")) img.srcset = val;
|
||||||
else if (!img.src || img.src.includes('data:')) img.src = val;
|
else if (!img.src || img.src.includes("data:")) img.src = val;
|
||||||
}
|
}
|
||||||
if (el.tagName === 'SOURCE') {
|
if (el.tagName === "SOURCE") {
|
||||||
const source = el as HTMLSourceElement;
|
const source = el as HTMLSourceElement;
|
||||||
if (name.includes('srcset')) source.srcset = val;
|
if (name.includes("srcset")) source.srcset = val;
|
||||||
}
|
}
|
||||||
if (el.tagName === 'VIDEO' || el.tagName === 'AUDIO') {
|
if (el.tagName === "VIDEO" || el.tagName === "AUDIO") {
|
||||||
const media = el as HTMLMediaElement;
|
const media = el as HTMLMediaElement;
|
||||||
if (!media.src) media.src = val;
|
if (!media.src) media.src = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background Image Promotion
|
// Background Image Promotion
|
||||||
if (val.match(/^(https?:\/\/|\/\/|\/)/) && !name.includes('href')) {
|
if (val.match(/^(https?:\/\/|\/\/|\/)/) && !name.includes("href")) {
|
||||||
const bg = htmlEl.style.backgroundImage;
|
const bg = htmlEl.style.backgroundImage;
|
||||||
if (!bg || bg === 'none') {
|
if (!bg || bg === "none") {
|
||||||
htmlEl.style.backgroundImage = `url('${val}')`;
|
htmlEl.style.backgroundImage = `url('${val}')`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,13 +236,13 @@ async function run() {
|
|||||||
// B. Ensure basic structural elements are visible post-scroll
|
// B. Ensure basic structural elements are visible post-scroll
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
if (body) {
|
if (body) {
|
||||||
body.style.setProperty('opacity', '1', 'important');
|
body.style.setProperty("opacity", "1", "important");
|
||||||
body.style.setProperty('visibility', 'visible', 'important');
|
body.style.setProperty("visibility", "visible", "important");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('⏳ Waiting for network idle...');
|
console.log("⏳ Waiting for network idle...");
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// 1.5 FINAL SETTLEMENT: Let any scroll-triggered JS finish
|
// 1.5 FINAL SETTLEMENT: Let any scroll-triggered JS finish
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
@@ -217,13 +255,17 @@ async function run() {
|
|||||||
const regexPatterns = [
|
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,
|
/(?: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
|
// Capture CSS url() inside style blocks
|
||||||
/url\(["']?([^"'\)]+)["']?\)/gi
|
/url\(["']?([^"'\)]+)["']?\)/gi,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const pattern of regexPatterns) {
|
for (const pattern of regexPatterns) {
|
||||||
let match;
|
let match;
|
||||||
while ((match = pattern.exec(content)) !== null) {
|
while ((match = pattern.exec(content)) !== null) {
|
||||||
try { foundAssets.add(new URL(match[1], targetUrl).href); } catch { }
|
try {
|
||||||
|
foundAssets.add(new URL(match[1], targetUrl).href);
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid URLs in content
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,10 +273,14 @@ async function run() {
|
|||||||
const srcsetRegex = /[a-z0-9-]+srcset=["']([^"']+)["']/gi;
|
const srcsetRegex = /[a-z0-9-]+srcset=["']([^"']+)["']/gi;
|
||||||
let match;
|
let match;
|
||||||
while ((match = srcsetRegex.exec(content)) !== null) {
|
while ((match = srcsetRegex.exec(content)) !== null) {
|
||||||
match[1].split(',').forEach(rule => {
|
match[1].split(",").forEach((rule) => {
|
||||||
const parts = rule.trim().split(/\s+/);
|
const parts = rule.trim().split(/\s+/);
|
||||||
if (parts[0] && !parts[0].startsWith('data:')) {
|
if (parts[0] && !parts[0].startsWith("data:")) {
|
||||||
try { foundAssets.add(new URL(parts[0], targetUrl).href); } catch { }
|
try {
|
||||||
|
foundAssets.add(new URL(parts[0], targetUrl).href);
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid srcset URLs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -246,38 +292,60 @@ async function run() {
|
|||||||
const local = await downloadFile(url, assetsDir);
|
const local = await downloadFile(url, assetsDir);
|
||||||
if (local) {
|
if (local) {
|
||||||
urlMap[url] = local;
|
urlMap[url] = local;
|
||||||
const clean = url.split('?')[0];
|
const clean = url.split("?")[0];
|
||||||
urlMap[clean] = local;
|
urlMap[clean] = local;
|
||||||
|
|
||||||
// Handle CSS recursively
|
// Handle CSS recursively
|
||||||
if (clean.endsWith('.css')) {
|
if (clean.endsWith(".css")) {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get(url, { headers: { 'User-Agent': USER_AGENT } });
|
const { data } = await axios.get(url, {
|
||||||
|
headers: { "User-Agent": USER_AGENT },
|
||||||
|
});
|
||||||
// Process CSS and save it
|
// Process CSS and save it
|
||||||
const processedCss = await processCssRecursively(data, url, assetsDir, urlMap);
|
const processedCss = await processCssRecursively(
|
||||||
const relPath = sanitizePath(new URL(url).hostname + new URL(url).pathname);
|
data,
|
||||||
|
url,
|
||||||
|
assetsDir,
|
||||||
|
urlMap,
|
||||||
|
);
|
||||||
|
const relPath = sanitizePath(
|
||||||
|
new URL(url).hostname + new URL(url).pathname,
|
||||||
|
);
|
||||||
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
|
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
|
||||||
} catch { }
|
} catch {
|
||||||
|
// Ignore CSS fetch/process errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🛠️ Finalizing Static Mirror...');
|
console.log("🛠️ Finalizing Static Mirror...");
|
||||||
let finalContent = content;
|
let finalContent = content;
|
||||||
|
|
||||||
// A. Apply URL Map Replacements
|
// A. Apply URL Map Replacements
|
||||||
// Longer paths first to prevent partial replacement errors
|
// Longer paths first to prevent partial replacement errors
|
||||||
const sortedUrls = Object.keys(urlMap).sort((a, b) => b.length - a.length);
|
const sortedUrls = Object.keys(urlMap).sort((a, b) => b.length - a.length);
|
||||||
if (sortedUrls.length > 0) {
|
if (sortedUrls.length > 0) {
|
||||||
const escaped = sortedUrls.map(u => u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
const escaped = sortedUrls.map((u) =>
|
||||||
|
u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||||
|
);
|
||||||
// Create a massive regex for single-pass replacement
|
// Create a massive regex for single-pass replacement
|
||||||
const masterRegex = new RegExp(`(${escaped.join('|')})`, 'g');
|
const masterRegex = new RegExp(`(${escaped.join("|")})`, "g");
|
||||||
finalContent = finalContent.replace(masterRegex, (match) => urlMap[match] || match);
|
finalContent = finalContent.replace(
|
||||||
|
masterRegex,
|
||||||
|
(match) => urlMap[match] || match,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// B. Global Root-Relative Path Cleanup
|
// B. Global Root-Relative Path Cleanup
|
||||||
// Catches things like /wp-content/ that weren't distinct assets or were missed
|
// Catches things like /wp-content/ that weren't distinct assets or were missed
|
||||||
const commonDirs = ['/wp-content/', '/wp-includes/', '/assets/', '/static/', '/images/'];
|
const commonDirs = [
|
||||||
|
"/wp-content/",
|
||||||
|
"/wp-includes/",
|
||||||
|
"/assets/",
|
||||||
|
"/static/",
|
||||||
|
"/images/",
|
||||||
|
];
|
||||||
for (const dir of commonDirs) {
|
for (const dir of commonDirs) {
|
||||||
const localDir = `./assets/${urlObj.hostname}${dir}`;
|
const localDir = `./assets/${urlObj.hostname}${dir}`;
|
||||||
finalContent = finalContent.split(`"${dir}`).join(`"${localDir}`);
|
finalContent = finalContent.split(`"${dir}`).join(`"${localDir}`);
|
||||||
@@ -287,7 +355,10 @@ async function run() {
|
|||||||
|
|
||||||
// C. Domain Nuke
|
// C. Domain Nuke
|
||||||
// Replace absolute links to the original domain with relative or #
|
// Replace absolute links to the original domain with relative or #
|
||||||
const domainPattern = new RegExp(`https?://(www\\.)?${urlObj.hostname.replace(/\./g, '\\.')}[^"']*`, 'gi');
|
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
|
// 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.
|
// For simplicity and "solidness", we'll rely on the specific replacements above first.
|
||||||
// This catch-all nuke ensures we don't leak requests.
|
// This catch-all nuke ensures we don't leak requests.
|
||||||
@@ -296,25 +367,30 @@ async function run() {
|
|||||||
// If we have a map for it, it should have been replaced.
|
// If we have a map for it, it should have been replaced.
|
||||||
// If not, it's likely a navigation link or an uncaptured asset.
|
// If not, it's likely a navigation link or an uncaptured asset.
|
||||||
// Safe fallback:
|
// Safe fallback:
|
||||||
return './';
|
return "./";
|
||||||
});
|
});
|
||||||
|
|
||||||
// D. Static Stability & Cleanup
|
// D. Static Stability & Cleanup
|
||||||
// Remove tracking/analytics/lazy-load scripts that ruins stability
|
// Remove tracking/analytics/lazy-load scripts that ruins stability
|
||||||
finalContent = finalContent.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (match, content) => {
|
finalContent = finalContent.replace(
|
||||||
|
/<script\b[^>]*>([\s\S]*?)<\/script>/gi,
|
||||||
|
(match, content) => {
|
||||||
const lower = content.toLowerCase();
|
const lower = content.toLowerCase();
|
||||||
if (lower.includes('google-analytics') ||
|
if (
|
||||||
lower.includes('gtag') ||
|
lower.includes("google-analytics") ||
|
||||||
lower.includes('fbq') ||
|
lower.includes("gtag") ||
|
||||||
lower.includes('lazy') ||
|
lower.includes("fbq") ||
|
||||||
lower.includes('tracker')) {
|
lower.includes("lazy") ||
|
||||||
return '';
|
lower.includes("tracker")
|
||||||
|
) {
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
return match;
|
return match;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// E. CSS Injections for Stability
|
// E. CSS Injections for Stability
|
||||||
const headEnd = finalContent.indexOf('</head>');
|
const headEnd = finalContent.indexOf("</head>");
|
||||||
if (headEnd > -1) {
|
if (headEnd > -1) {
|
||||||
const stabilityCss = `
|
const stabilityCss = `
|
||||||
<style>
|
<style>
|
||||||
@@ -340,16 +416,18 @@ async function run() {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
</style>`;
|
</style>`;
|
||||||
finalContent = finalContent.slice(0, headEnd) + stabilityCss + finalContent.slice(headEnd);
|
finalContent =
|
||||||
|
finalContent.slice(0, headEnd) +
|
||||||
|
stabilityCss +
|
||||||
|
finalContent.slice(headEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
const finalPath = path.join(domainDir, htmlFilename);
|
const finalPath = path.join(domainDir, htmlFilename);
|
||||||
fs.writeFileSync(finalPath, finalContent);
|
fs.writeFileSync(finalPath, finalContent);
|
||||||
console.log(`✅ SUCCESS: Cloned to ${finalPath}`);
|
console.log(`✅ SUCCESS: Cloned to ${finalPath}`);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ FATAL ERROR:', err);
|
console.error("❌ FATAL ERROR:", err);
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,27 @@
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import scrape from 'website-scraper';
|
import scrape from "website-scraper";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import PuppeteerPlugin from 'website-scraper-puppeteer';
|
import PuppeteerPlugin from "website-scraper-puppeteer";
|
||||||
import path from 'node:path';
|
import path from "node:path";
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from "node:url";
|
||||||
import fs from 'node:fs';
|
import fs from "node:fs";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
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);
|
|
||||||
|
|
||||||
// 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';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
|
|
||||||
// Remove leading slash
|
|
||||||
if (safePath.startsWith('/')) safePath = safePath.substring(1);
|
|
||||||
|
|
||||||
// Sanitization
|
|
||||||
safePath = safePath.replace(/[:*?"<>|]/g, '_');
|
|
||||||
|
|
||||||
// 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() {
|
async function run() {
|
||||||
const targetUrl = process.argv[2];
|
const targetUrl = process.argv[2];
|
||||||
if (!targetUrl) {
|
if (!targetUrl) {
|
||||||
console.error('Usage: npm run clone-website <URL> [output-dir]');
|
console.error("Usage: npm run clone-website <URL> [output-dir]");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlObj = new URL(targetUrl);
|
const urlObj = new URL(targetUrl);
|
||||||
const domain = urlObj.hostname;
|
const domain = urlObj.hostname;
|
||||||
const safeDomain = domain.replace(/[^a-z0-9-]/gi, '_');
|
const safeDomain = domain.replace(/[^a-z0-9-]/gi, "_");
|
||||||
const outputDir = process.argv[3]
|
const outputDir = process.argv[3]
|
||||||
? path.resolve(process.cwd(), process.argv[3])
|
? path.resolve(process.cwd(), process.argv[3])
|
||||||
: path.resolve(__dirname, '../cloned-websites', safeDomain);
|
: path.resolve(__dirname, "../cloned-websites", safeDomain);
|
||||||
|
|
||||||
if (fs.existsSync(outputDir)) {
|
if (fs.existsSync(outputDir)) {
|
||||||
console.log(`Cleaning existing directory: ${outputDir}`);
|
console.log(`Cleaning existing directory: ${outputDir}`);
|
||||||
@@ -83,30 +41,35 @@ async function run() {
|
|||||||
new PuppeteerPlugin({
|
new PuppeteerPlugin({
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
headless: true,
|
headless: true,
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
|
args: [
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-setuid-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
scrollToBottom: { timeout: 10000, viewportN: 10 },
|
scrollToBottom: { timeout: 10000, viewportN: 10 },
|
||||||
blockNavigation: false
|
blockNavigation: false,
|
||||||
}),
|
}),
|
||||||
new class LoggerPlugin {
|
new (class LoggerPlugin {
|
||||||
apply(registerAction: any) {
|
apply(registerAction: any) {
|
||||||
registerAction('onResourceSaved', ({ resource }: any) => {
|
registerAction("onResourceSaved", ({ resource }: any) => {
|
||||||
console.log(` 💾 Saved: ${resource.url} -> ${resource.filename}`);
|
console.log(` 💾 Saved: ${resource.url} -> ${resource.filename}`);
|
||||||
});
|
});
|
||||||
registerAction('onResourceError', ({ resource, error }: any) => {
|
registerAction("onResourceError", ({ resource, error }: any) => {
|
||||||
console.error(` ❌ Error: ${resource.url} - ${error.message}`);
|
console.error(` ❌ Error: ${resource.url} - ${error.message}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
})(),
|
||||||
new class FilenamePlugin {
|
new (class FilenamePlugin {
|
||||||
apply(registerAction: any) {
|
apply(registerAction: any) {
|
||||||
registerAction('generateFilename', ({ resource }: any) => {
|
registerAction("generateFilename", ({ resource }: any) => {
|
||||||
const u = new URL(resource.url);
|
const u = new URL(resource.url);
|
||||||
let filename = u.pathname;
|
let filename = u.pathname;
|
||||||
|
|
||||||
// normalize
|
// normalize
|
||||||
if (filename.endsWith('/')) filename += 'index.html';
|
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
|
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 it's an external asset, put it in a separate folder
|
||||||
if (u.hostname !== domain) {
|
if (u.hostname !== domain) {
|
||||||
@@ -114,70 +77,86 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize filename
|
// Sanitize filename
|
||||||
filename = filename.split('/').map(part => part.replace(/[^a-z0-9._-]/gi, '_')).join('/');
|
filename = filename
|
||||||
|
.split("/")
|
||||||
|
.map((part) => part.replace(/[^a-z0-9._-]/gi, "_"))
|
||||||
|
.join("/");
|
||||||
|
|
||||||
// Remove leading slash
|
// Remove leading slash
|
||||||
if (filename.startsWith('/')) filename = filename.substring(1);
|
if (filename.startsWith("/")) filename = filename.substring(1);
|
||||||
|
|
||||||
// Handle "Unnamed page" by checking if empty
|
// Handle "Unnamed page" by checking if empty
|
||||||
if (!filename || filename === 'index.html') return { filename: 'index.html' };
|
if (!filename || filename === "index.html")
|
||||||
|
return { filename: "index.html" };
|
||||||
|
|
||||||
return { filename };
|
return { filename };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
})(),
|
||||||
],
|
],
|
||||||
|
|
||||||
urlFilter: (url: string) => {
|
urlFilter: (url: string) => {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
const isTargetDomain = u.hostname === domain;
|
const isTargetDomain = u.hostname === domain;
|
||||||
const isGoogleFonts = u.hostname.includes('fonts.googleapis.com') || u.hostname.includes('fonts.gstatic.com');
|
const isGoogleFonts =
|
||||||
|
u.hostname.includes("fonts.googleapis.com") ||
|
||||||
|
u.hostname.includes("fonts.gstatic.com");
|
||||||
// Allow assets from anywhere
|
// 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);
|
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
|
// 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/');
|
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;
|
return isTargetDomain || isAsset || isCommonAsset || isGoogleFonts;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
sources: [
|
sources: [
|
||||||
{ selector: 'img', attr: 'src' },
|
{ selector: "img", attr: "src" },
|
||||||
{ selector: 'img', attr: 'srcset' },
|
{ selector: "img", attr: "srcset" },
|
||||||
{ selector: 'source', attr: 'src' },
|
{ selector: "source", attr: "src" },
|
||||||
{ selector: 'source', attr: 'srcset' },
|
{ selector: "source", attr: "srcset" },
|
||||||
{ selector: 'link[rel="stylesheet"]', attr: 'href' },
|
{ selector: 'link[rel="stylesheet"]', attr: "href" },
|
||||||
{ selector: 'link[rel="preload"]', attr: 'href' },
|
{ selector: 'link[rel="preload"]', attr: "href" },
|
||||||
{ selector: 'link[rel="prefetch"]', attr: 'href' },
|
{ selector: 'link[rel="prefetch"]', attr: "href" },
|
||||||
{ selector: 'script', attr: 'src' },
|
{ selector: "script", attr: "src" },
|
||||||
{ selector: 'video', attr: 'src' },
|
{ selector: "video", attr: "src" },
|
||||||
{ selector: 'video', attr: 'poster' },
|
{ selector: "video", attr: "poster" },
|
||||||
{ selector: 'iframe', attr: 'src' },
|
{ selector: "iframe", attr: "src" },
|
||||||
{ selector: 'link[rel*="icon"]', attr: 'href' },
|
{ selector: 'link[rel*="icon"]', attr: "href" },
|
||||||
{ selector: 'link[rel="manifest"]', attr: 'href' },
|
{ selector: 'link[rel="manifest"]', attr: "href" },
|
||||||
{ selector: 'meta[property="og:image"]', attr: 'content' }
|
{ selector: 'meta[property="og:image"]', attr: "content" },
|
||||||
],
|
],
|
||||||
|
|
||||||
request: {
|
request: {
|
||||||
headers: {
|
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'
|
"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 {
|
try {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const result = await scrape(options);
|
const result = await scrape(options);
|
||||||
console.log(`\n✅ Successfully cloned ${result.length} resources to ${outputDir}`);
|
console.log(
|
||||||
|
`\n✅ Successfully cloned ${result.length} resources to ${outputDir}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Post-processing: Sanitize HTML to remove Next.js hydration scripts
|
// Post-processing: Sanitize HTML to remove Next.js hydration scripts
|
||||||
// This prevents the static site from trying to "hydrate" and breaking images/links
|
// This prevents the static site from trying to "hydrate" and breaking images/links
|
||||||
console.log('🧹 Sanitizing HTML files...');
|
console.log("🧹 Sanitizing HTML files...");
|
||||||
sanitizeHtmlFiles(outputDir);
|
sanitizeHtmlFiles(outputDir);
|
||||||
|
|
||||||
console.log(`open "${path.join(outputDir, 'index.html')}"`);
|
console.log(`open "${path.join(outputDir, "index.html")}"`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error cloning website:', error);
|
console.error("❌ Error cloning website:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,28 +167,44 @@ function sanitizeHtmlFiles(dir: string) {
|
|||||||
const fullPath = path.join(dir, file);
|
const fullPath = path.join(dir, file);
|
||||||
if (fs.statSync(fullPath).isDirectory()) {
|
if (fs.statSync(fullPath).isDirectory()) {
|
||||||
sanitizeHtmlFiles(fullPath);
|
sanitizeHtmlFiles(fullPath);
|
||||||
} else if (file.endsWith('.html')) {
|
} else if (file.endsWith(".html")) {
|
||||||
let content = fs.readFileSync(fullPath, 'utf8');
|
let content = fs.readFileSync(fullPath, "utf8");
|
||||||
|
|
||||||
// Remove Next.js data script
|
// Remove Next.js data script
|
||||||
content = content.replace(/<script id="__NEXT_DATA__"[\s\S]*?<\/script>/gi, '');
|
content = content.replace(
|
||||||
|
/<script id="__NEXT_DATA__"[\s\S]*?<\/script>/gi,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
// Remove Next.js chunk scripts (hydration)
|
// Remove Next.js chunk scripts (hydration)
|
||||||
// match <script src="..._next/static/chunks..." ...
|
// match <script src="..._next/static/chunks..." ...
|
||||||
content = content.replace(/<script[^>]+src="[^"]*\/_next\/static\/chunks\/[^"]*"[^>]*><\/script>/gi, '');
|
content = content.replace(
|
||||||
content = content.replace(/<script[^>]+src="[^"]*\/_next\/static\/[^"]*Manifest\.js"[^>]*><\/script>/gi, '');
|
/<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
|
// Convert Breeze dynamic script/styles into actual tags if possible
|
||||||
// match <div class="breeze-scripts-load" ...>URL</div>
|
// match <div class="breeze-scripts-load" ...>URL</div>
|
||||||
content = content.replace(/<div[^>]+class="breeze-scripts-load"[^>]*>([^<]+)<\/div>/gi, (match, url) => {
|
content = content.replace(
|
||||||
if (url.endsWith('.css')) return `<link rel="stylesheet" href="${url}">`;
|
/<div[^>]+class="breeze-scripts-load"[^>]*>([^<]+)<\/div>/gi,
|
||||||
|
(match, url) => {
|
||||||
|
if (url.endsWith(".css"))
|
||||||
|
return `<link rel="stylesheet" href="${url}">`;
|
||||||
return `<script src="${url}"></script>`;
|
return `<script src="${url}"></script>`;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Inject Fonts (Fix for missing dynamic fonts)
|
// Inject Fonts (Fix for missing dynamic fonts)
|
||||||
// We inject Inter and Montserrat as safe defaults for industrial/modern sites
|
// We inject Inter and Montserrat as safe defaults for industrial/modern sites
|
||||||
// Check specifically for a stylesheet link to google fonts
|
// Check specifically for a stylesheet link to google fonts
|
||||||
const hasGoogleFontStylesheet = /<link[^>]+rel="stylesheet"[^>]+href="[^"]*fonts\.googleapis\.com/i.test(content);
|
const hasGoogleFontStylesheet =
|
||||||
|
/<link[^>]+rel="stylesheet"[^>]+href="[^"]*fonts\.googleapis\.com/i.test(
|
||||||
|
content,
|
||||||
|
);
|
||||||
if (!hasGoogleFontStylesheet) {
|
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 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>
|
const styleBlock = `<style>
|
||||||
@@ -217,7 +212,7 @@ function sanitizeHtmlFiles(dir: string) {
|
|||||||
body, .body-font, p, span, li, a { font-family: var(--main-font) !important; }
|
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; }
|
h1, h2, h3, h4, h5, h6, .title-font, .heading-font { font-family: var(--heading-font) !important; }
|
||||||
</style>`;
|
</style>`;
|
||||||
content = content.replace('</head>', `${fontLink}${styleBlock}</head>`);
|
content = content.replace("</head>", `${fontLink}${styleBlock}</head>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force column layout on product pages
|
// Force column layout on product pages
|
||||||
@@ -233,7 +228,7 @@ function sanitizeHtmlFiles(dir: string) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>`;
|
</script>`;
|
||||||
content = content.replace('</body>', `${layoutScript}</body>`);
|
content = content.replace("</body>", `${layoutScript}</body>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(fullPath, content);
|
fs.writeFileSync(fullPath, content);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import scrape from 'website-scraper';
|
import scrape from "website-scraper";
|
||||||
import PuppeteerPlugin from 'website-scraper-puppeteer';
|
import PuppeteerPlugin from "website-scraper-puppeteer";
|
||||||
import path from 'path';
|
import path from "path";
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from "url";
|
||||||
import fs from 'fs';
|
import fs from "fs";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -11,45 +11,55 @@ const __dirname = path.dirname(__filename);
|
|||||||
class PortfolioPlugin {
|
class PortfolioPlugin {
|
||||||
apply(registerAction: any) {
|
apply(registerAction: any) {
|
||||||
// 1. Add more sources before starting
|
// 1. Add more sources before starting
|
||||||
registerAction('beforeStart', ({ options }: any) => {
|
registerAction("beforeStart", ({ options }: any) => {
|
||||||
if (!options.sources) options.sources = [];
|
if (!options.sources) options.sources = [];
|
||||||
options.sources.push({ selector: 'img', attr: 'data-nimg' });
|
options.sources.push({ selector: "img", attr: "data-nimg" });
|
||||||
options.sources.push({ selector: 'img', attr: 'data-src' });
|
options.sources.push({ selector: "img", attr: "data-src" });
|
||||||
options.sources.push({ selector: 'img', attr: 'data-srcset' });
|
options.sources.push({ selector: "img", attr: "data-srcset" });
|
||||||
options.sources.push({ selector: 'video', attr: 'poster' });
|
options.sources.push({ selector: "video", attr: "poster" });
|
||||||
options.sources.push({ selector: 'source', attr: 'data-srcset' });
|
options.sources.push({ selector: "source", attr: "data-srcset" });
|
||||||
options.sources.push({ selector: '[style*="background-image"]', attr: 'style' });
|
options.sources.push({
|
||||||
options.sources.push({ selector: 'link[as="font"]', attr: 'href' });
|
selector: '[style*="background-image"]',
|
||||||
options.sources.push({ selector: 'link[as="image"]', attr: 'href' });
|
attr: "style",
|
||||||
options.sources.push({ selector: 'link[as="style"]', attr: 'href' });
|
});
|
||||||
options.sources.push({ selector: 'link[as="script"]', attr: 'href' });
|
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
|
// 2. Sanitize filenames and handle Next.js optimized images
|
||||||
registerAction('generateFilename', ({ resource, filename }: any) => {
|
registerAction("generateFilename", ({ resource, filename }: any) => {
|
||||||
const url = resource.getUrl();
|
const url = resource.getUrl();
|
||||||
let result = filename;
|
let result = filename;
|
||||||
|
|
||||||
// Handle Next.js optimized images: /_next/image?url=...&w=...
|
// Handle Next.js optimized images: /_next/image?url=...&w=...
|
||||||
if (url.includes('/_next/image')) {
|
if (url.includes("/_next/image")) {
|
||||||
try {
|
try {
|
||||||
const urlParams = new URL(url).searchParams;
|
const urlParams = new URL(url).searchParams;
|
||||||
const originalUrl = urlParams.get('url');
|
const originalUrl = urlParams.get("url");
|
||||||
if (originalUrl) {
|
if (originalUrl) {
|
||||||
const cleanPath = originalUrl.split('?')[0];
|
const cleanPath = originalUrl.split("?")[0];
|
||||||
const ext = path.extname(cleanPath) || '.webp';
|
const ext = path.extname(cleanPath) || ".webp";
|
||||||
const name = path.basename(cleanPath, ext);
|
const name = path.basename(cleanPath, ext);
|
||||||
const width = urlParams.get('w') || 'auto';
|
const width = urlParams.get("w") || "auto";
|
||||||
result = `_next/optimized/${name}-${width}${ext}`;
|
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
|
// 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
|
// We split by / to ensure we only replace .app at the end of a directory name or filename
|
||||||
result = result.split('/').map((segment: string) =>
|
result = result
|
||||||
segment.endsWith('.app') ? segment.replace(/\.app$/, '-app') : segment
|
.split("/")
|
||||||
).join('/');
|
.map((segment: string) =>
|
||||||
|
segment.endsWith(".app")
|
||||||
|
? segment.replace(/\.app$/, "-app")
|
||||||
|
: segment,
|
||||||
|
)
|
||||||
|
.join("/");
|
||||||
|
|
||||||
return { filename: result };
|
return { filename: result };
|
||||||
});
|
});
|
||||||
@@ -59,19 +69,23 @@ class PortfolioPlugin {
|
|||||||
async function cloneWebsite() {
|
async function cloneWebsite() {
|
||||||
const url = process.argv[2];
|
const url = process.argv[2];
|
||||||
if (!url) {
|
if (!url) {
|
||||||
console.error('Please provide a URL as an argument.');
|
console.error("Please provide a URL as an argument.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const domain = new URL(url).hostname;
|
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
|
// Sanitize top-level folder name for Mac
|
||||||
if (outputDirName.endsWith('.app')) {
|
if (outputDirName.endsWith(".app")) {
|
||||||
outputDirName = outputDirName.replace(/\.app$/, '-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)) {
|
if (fs.existsSync(outputDir)) {
|
||||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||||
@@ -88,61 +102,84 @@ async function cloneWebsite() {
|
|||||||
requestConcurrency: 10,
|
requestConcurrency: 10,
|
||||||
plugins: [
|
plugins: [
|
||||||
new PuppeteerPlugin({
|
new PuppeteerPlugin({
|
||||||
launchOptions: { headless: true, args: ['--no-sandbox'] },
|
launchOptions: { headless: true, args: ["--no-sandbox"] },
|
||||||
gotoOptions: { waitUntil: 'networkidle0', timeout: 60000 },
|
gotoOptions: { waitUntil: "networkidle0", timeout: 60000 },
|
||||||
scrollToBottom: { timeout: 20000, viewportN: 20 },
|
scrollToBottom: { timeout: 20000, viewportN: 20 },
|
||||||
}),
|
}),
|
||||||
new PortfolioPlugin()
|
new PortfolioPlugin(),
|
||||||
],
|
],
|
||||||
sources: [
|
sources: [
|
||||||
{ selector: 'img', attr: 'src' },
|
{ selector: "img", attr: "src" },
|
||||||
{ selector: 'img', attr: 'srcset' },
|
{ selector: "img", attr: "srcset" },
|
||||||
{ selector: 'img', attr: 'data-src' },
|
{ selector: "img", attr: "data-src" },
|
||||||
{ selector: 'img', attr: 'data-srcset' },
|
{ selector: "img", attr: "data-srcset" },
|
||||||
{ selector: 'link[rel="stylesheet"]', attr: 'href' },
|
{ selector: 'link[rel="stylesheet"]', attr: "href" },
|
||||||
{ selector: 'link[rel*="icon"]', attr: 'href' },
|
{ selector: 'link[rel*="icon"]', attr: "href" },
|
||||||
{ selector: 'script', attr: 'src' },
|
{ selector: "script", attr: "src" },
|
||||||
{ selector: 'link[rel="preload"]', attr: 'href' },
|
{ selector: 'link[rel="preload"]', attr: "href" },
|
||||||
{ selector: 'link[rel="prefetch"]', attr: 'href' },
|
{ selector: 'link[rel="prefetch"]', attr: "href" },
|
||||||
{ selector: 'link[rel="modulepreload"]', attr: 'href' },
|
{ selector: 'link[rel="modulepreload"]', attr: "href" },
|
||||||
{ selector: 'link[rel="apple-touch-icon"]', attr: 'href' },
|
{ selector: 'link[rel="apple-touch-icon"]', attr: "href" },
|
||||||
{ selector: 'link[rel="mask-icon"]', attr: 'href' },
|
{ selector: 'link[rel="mask-icon"]', attr: "href" },
|
||||||
{ selector: 'source', attr: 'src' },
|
{ selector: "source", attr: "src" },
|
||||||
{ selector: 'source', attr: 'srcset' },
|
{ selector: "source", attr: "srcset" },
|
||||||
{ selector: 'video', attr: 'src' },
|
{ selector: "video", attr: "src" },
|
||||||
{ selector: 'video', attr: 'poster' },
|
{ selector: "video", attr: "poster" },
|
||||||
{ selector: 'audio', attr: 'src' },
|
{ selector: "audio", attr: "src" },
|
||||||
{ selector: 'iframe', attr: 'src' },
|
{ selector: "iframe", attr: "src" },
|
||||||
{ selector: 'meta[property="og:image"]', attr: 'content' },
|
{ selector: 'meta[property="og:image"]', attr: "content" },
|
||||||
{ selector: 'meta[name="twitter:image"]', attr: 'content' },
|
{ selector: 'meta[name="twitter:image"]', attr: "content" },
|
||||||
{ selector: '[style]', attr: 'style' },
|
{ selector: "[style]", attr: "style" },
|
||||||
],
|
],
|
||||||
urlFilter: (link: string) => {
|
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 isAsset =
|
||||||
const isNextAsset = link.includes('/_next/');
|
/\.(js|css|jpg|jpeg|png|gif|svg|webp|woff|woff2|ttf|eot|otf|mp4|webm|mov|ogg|pdf|ico)(\?.*)?$/i.test(
|
||||||
const isSameDomain = link.startsWith(url) || link.startsWith('/') || !link.includes('://') || link.includes(domain);
|
link,
|
||||||
const isGoogleTagManager = link.includes('googletagmanager.com');
|
);
|
||||||
const isAnalytics = link.includes('analytics.mintel.me');
|
const isNextAsset = link.includes("/_next/");
|
||||||
const isVercelApp = link.includes('vercel.app');
|
const isSameDomain =
|
||||||
const isDataUrl = link.startsWith('data:');
|
link.startsWith(url) ||
|
||||||
const isMailto = link.startsWith('mailto:');
|
link.startsWith("/") ||
|
||||||
const isTel = link.startsWith('tel:');
|
!link.includes("://") ||
|
||||||
return (isAsset || isNextAsset || isSameDomain || isGoogleTagManager || isAnalytics || isVercelApp) && !isDataUrl && !isMailto && !isTel;
|
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: [
|
subdirectories: [
|
||||||
{ directory: 'img', extensions: ['.jpg', '.png', '.svg', '.webp', '.gif', '.ico'] },
|
{
|
||||||
{ directory: 'js', extensions: ['.js'] },
|
directory: "img",
|
||||||
{ directory: 'css', extensions: ['.css'] },
|
extensions: [".jpg", ".png", ".svg", ".webp", ".gif", ".ico"],
|
||||||
{ directory: 'fonts', extensions: ['.woff', '.woff2', '.ttf', '.eot', '.otf'] },
|
},
|
||||||
{ directory: 'videos', extensions: ['.mp4', '.webm', '.mov', '.ogg'] },
|
{ 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}`);
|
console.log(`Location: ${outputDir}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error cloning website:', error);
|
console.error("❌ Error cloning website:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import * as readline from "node:readline/promises";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { renderToFile } from "@react-pdf/renderer";
|
import { renderToFile } from "@react-pdf/renderer";
|
||||||
import {
|
import { calculateTotals } from "../src/logic/pricing/calculator.js";
|
||||||
calculatePositions,
|
|
||||||
calculateTotals,
|
|
||||||
} from "../src/logic/pricing/calculator.js";
|
|
||||||
import { CombinedQuotePDF } from "../src/components/CombinedQuotePDF.js";
|
import { CombinedQuotePDF } from "../src/components/CombinedQuotePDF.js";
|
||||||
import { initialState, PRICING } from "../src/logic/pricing/constants.js";
|
import { initialState, PRICING } from "../src/logic/pricing/constants.js";
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +15,6 @@ import {
|
|||||||
} from "../src/logic/content-provider.js";
|
} from "../src/logic/content-provider.js";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from "cheerio";
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from "child_process";
|
||||||
import * as fs from 'fs';
|
import * as fs from "fs";
|
||||||
import * as path from 'path';
|
import * as path from "path";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PageSpeed Test Script
|
* PageSpeed Test Script
|
||||||
@@ -13,10 +13,15 @@ import * as path from 'path';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const targetUrl =
|
const targetUrl =
|
||||||
process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'https://testing.mintel.me';
|
process.argv[2] ||
|
||||||
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20;
|
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'mintel';
|
"https://testing.mintel.me";
|
||||||
const gatekeeperCookie = process.env.AUTH_COOKIE_NAME || 'mintel_gatekeeper_session';
|
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() {
|
async function main() {
|
||||||
console.log(`\n🚀 Starting PageSpeed test for: ${targetUrl}`);
|
console.log(`\n🚀 Starting PageSpeed test for: ${targetUrl}`);
|
||||||
@@ -24,7 +29,7 @@ async function main() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Fetch Sitemap
|
// 1. Fetch Sitemap
|
||||||
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
const sitemapUrl = `${targetUrl.replace(/\/$/, "")}/sitemap.xml`;
|
||||||
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||||
|
|
||||||
// We might need to bypass gatekeeper for the sitemap fetch too
|
// 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 });
|
const $ = cheerio.load(response.data, { xmlMode: true });
|
||||||
let urls = $('url loc')
|
let urls = $("url loc")
|
||||||
.map((i, el) => $(el).text())
|
.map((_i, el) => $(el).text())
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
// Cleanup, filter and normalize domains to targetUrl
|
// Cleanup, filter and normalize domains to targetUrl
|
||||||
const urlPattern = /https?:\/\/[^\/]+/;
|
const urlPattern = /https?:\/\/[^\/]+/;
|
||||||
urls = [...new Set(urls)]
|
urls = [...new Set(urls)]
|
||||||
.filter((u) => u.startsWith('http'))
|
.filter((u) => u.startsWith("http"))
|
||||||
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
|
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, "")))
|
||||||
.sort();
|
.sort();
|
||||||
|
|
||||||
console.log(`✅ Found ${urls.length} URLs in sitemap.`);
|
console.log(`✅ Found ${urls.length} URLs in sitemap.`);
|
||||||
|
|
||||||
if (urls.length === 0) {
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +64,9 @@ async function main() {
|
|||||||
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
|
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
|
||||||
);
|
);
|
||||||
// Try to pick a variety: home, some products, some blog posts
|
// 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));
|
const others = urls.filter((u) => !home.includes(u));
|
||||||
urls = [...home, ...others.slice(0, limit - home.length)];
|
urls = [...home, ...others.slice(0, limit - home.length)];
|
||||||
}
|
}
|
||||||
@@ -69,7 +76,7 @@ async function main() {
|
|||||||
|
|
||||||
// 2. Prepare LHCI command
|
// 2. Prepare LHCI command
|
||||||
// We use --collect.url multiple times
|
// 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
|
// Handle authentication for staging/testing
|
||||||
// Lighthouse can set cookies via --collect.settings.extraHeaders
|
// Lighthouse can set cookies via --collect.settings.extraHeaders
|
||||||
@@ -77,12 +84,15 @@ async function main() {
|
|||||||
Cookie: `${gatekeeperCookie}=${gatekeeperPassword}`,
|
Cookie: `${gatekeeperCookie}=${gatekeeperPassword}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const chromePath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
|
const chromePath =
|
||||||
const chromePathArg = chromePath ? `--collect.chromePath="${chromePath}"` : '';
|
process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||||
|
const chromePathArg = chromePath
|
||||||
|
? `--collect.chromePath="${chromePath}"`
|
||||||
|
: "";
|
||||||
|
|
||||||
// Clean up old reports
|
// Clean up old reports
|
||||||
if (fs.existsSync('.lighthouseci')) {
|
if (fs.existsSync(".lighthouseci")) {
|
||||||
fs.rmSync('.lighthouseci', { recursive: true, force: true });
|
fs.rmSync(".lighthouseci", { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Using a more robust way to execute and capture output
|
// Using a more robust way to execute and capture output
|
||||||
@@ -93,27 +103,31 @@ async function main() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(lhciCommand, {
|
execSync(lhciCommand, {
|
||||||
encoding: 'utf8',
|
encoding: "utf8",
|
||||||
stdio: 'inherit',
|
stdio: "inherit",
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} 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
|
// We continue to show the table even if assertions failed
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Summarize Results (Local & Independent)
|
// 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)) {
|
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`);
|
console.log(`\n📊 PageSpeed Summary (FOSS - Local Report):\n`);
|
||||||
|
|
||||||
const summaryTable = manifest.map((entry: any) => {
|
const summaryTable = manifest.map((entry: any) => {
|
||||||
const s = entry.summary;
|
const s = entry.summary;
|
||||||
return {
|
return {
|
||||||
URL: entry.url.replace(targetUrl, ''),
|
URL: entry.url.replace(targetUrl, ""),
|
||||||
Perf: Math.round(s.performance * 100),
|
Perf: Math.round(s.performance * 100),
|
||||||
Acc: Math.round(s.accessibility * 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),
|
SEO: Math.round(s.seo * 100),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -123,24 +137,30 @@ async function main() {
|
|||||||
// Calculate Average
|
// Calculate Average
|
||||||
const avg = {
|
const avg = {
|
||||||
Perf: Math.round(
|
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(
|
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(
|
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(
|
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(`\n📈 Average Scores:`);
|
||||||
console.log(` Performance: ${avg.Perf > 90 ? '✅' : '⚠️'} ${avg.Perf}`);
|
console.log(
|
||||||
console.log(` Accessibility: ${avg.Acc > 90 ? '✅' : '⚠️'} ${avg.Acc}`);
|
` Performance: ${avg.Perf > 90 ? "✅" : "⚠️"} ${avg.Perf}`,
|
||||||
console.log(` Best Practices: ${avg.BP > 90 ? '✅' : '⚠️'} ${avg.BP}`);
|
);
|
||||||
console.log(` SEO: ${avg.SEO > 90 ? '✅' : '⚠️'} ${avg.SEO}`);
|
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!`);
|
console.log(`\n✨ PageSpeed tests completed successfully!`);
|
||||||
|
|||||||
@@ -3,83 +3,90 @@
|
|||||||
* Verify components can be imported and used
|
* 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
|
// Test 1: Check if components can be imported
|
||||||
try {
|
try {
|
||||||
const YouTubePath = join(process.cwd(), 'src', 'components', 'YouTubeEmbed.astro');
|
console.log("✅ YouTubeEmbed.astro exists");
|
||||||
const TwitterPath = join(process.cwd(), 'src', 'components', 'TwitterEmbed.astro');
|
console.log("✅ TwitterEmbed.astro exists");
|
||||||
const GenericPath = join(process.cwd(), 'src', 'components', 'GenericEmbed.astro');
|
console.log("✅ GenericEmbed.astro exists");
|
||||||
|
|
||||||
console.log('✅ YouTubeEmbed.astro exists');
|
|
||||||
console.log('✅ TwitterEmbed.astro exists');
|
|
||||||
console.log('✅ GenericEmbed.astro exists');
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('❌ Component import error:', error);
|
console.log("❌ Component import error:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test 2: Check demo post accessibility
|
// Test 2: Check demo post accessibility
|
||||||
try {
|
try {
|
||||||
const demoPath = join(process.cwd(), 'src', 'pages', 'blog', 'embed-demo.astro');
|
const demoPath = join(
|
||||||
const { readFileSync } = require('fs');
|
process.cwd(),
|
||||||
|
"src",
|
||||||
|
"pages",
|
||||||
|
"blog",
|
||||||
|
"embed-demo.astro",
|
||||||
|
);
|
||||||
|
const { readFileSync } = require("fs");
|
||||||
|
|
||||||
if (require('fs').existsSync(demoPath)) {
|
if (require("fs").existsSync(demoPath)) {
|
||||||
const content = readFileSync(demoPath, 'utf-8');
|
const content = readFileSync(demoPath, "utf-8");
|
||||||
|
|
||||||
// Check if demo has proper structure
|
// Check if demo has proper structure
|
||||||
const hasImports = content.includes('import YouTubeEmbed') &&
|
const hasImports =
|
||||||
content.includes('import TwitterEmbed') &&
|
content.includes("import YouTubeEmbed") &&
|
||||||
content.includes('import GenericEmbed');
|
content.includes("import TwitterEmbed") &&
|
||||||
|
content.includes("import GenericEmbed");
|
||||||
|
|
||||||
const hasUsage = content.includes('<YouTubeEmbed') &&
|
const hasUsage =
|
||||||
content.includes('<TwitterEmbed') &&
|
content.includes("<YouTubeEmbed") &&
|
||||||
content.includes('<GenericEmbed>');
|
content.includes("<TwitterEmbed") &&
|
||||||
|
content.includes("<GenericEmbed>");
|
||||||
|
|
||||||
if (hasImports && hasUsage) {
|
if (hasImports && hasUsage) {
|
||||||
console.log('✅ Demo post has correct imports and usage');
|
console.log("✅ Demo post has correct imports and usage");
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ Demo post missing imports or usage');
|
console.log("❌ Demo post missing imports or usage");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it has BaseLayout
|
// Check if it has BaseLayout
|
||||||
if (content.includes('BaseLayout')) {
|
if (content.includes("BaseLayout")) {
|
||||||
console.log('✅ Demo post uses BaseLayout');
|
console.log("✅ Demo post uses BaseLayout");
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ Demo post missing BaseLayout');
|
console.log("❌ Demo post missing BaseLayout");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('❌ Demo post check error:', error);
|
console.log("❌ Demo post check error:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test 3: Check blogPosts array
|
// Test 3: Check blogPosts array
|
||||||
try {
|
try {
|
||||||
const blogPostsPath = join(process.cwd(), 'src', 'data', 'blogPosts.ts');
|
const blogPostsPath = join(process.cwd(), "src", "data", "blogPosts.ts");
|
||||||
const { readFileSync } = require('fs');
|
const { readFileSync } = require("fs");
|
||||||
|
|
||||||
const content = readFileSync(blogPostsPath, 'utf-8');
|
const content = readFileSync(blogPostsPath, "utf-8");
|
||||||
|
|
||||||
// Check if embed-demo needs to be added
|
// Check if embed-demo needs to be added
|
||||||
if (!content.includes('embed-demo')) {
|
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(
|
||||||
console.log(' But it should still be accessible at /blog/embed-demo directly');
|
"⚠️ 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 {
|
} else {
|
||||||
console.log('✅ embed-demo found in blogPosts array');
|
console.log("✅ embed-demo found in blogPosts array");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('❌ blogPosts check error:', error);
|
console.log("❌ blogPosts check error:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n' + '='.repeat(60));
|
console.log("\n" + "=".repeat(60));
|
||||||
console.log('📋 SUMMARY:');
|
console.log("📋 SUMMARY:");
|
||||||
console.log('• Components are created and structured correctly');
|
console.log("• Components are created and structured correctly");
|
||||||
console.log('• Demo post exists at src/pages/blog/embed-demo.astro');
|
console.log("• Demo post exists at src/pages/blog/embed-demo.astro");
|
||||||
console.log('• Demo post has all required imports and usage');
|
console.log("• Demo post has all required imports and usage");
|
||||||
console.log('\n🔧 TO FIX BLOG LISTING:');
|
console.log("\n🔧 TO FIX BLOG LISTING:");
|
||||||
console.log('Add embed-demo to src/data/blogPosts.ts array');
|
console.log("Add embed-demo to src/data/blogPosts.ts array");
|
||||||
console.log('\n🚀 TO TEST COMPONENTS:');
|
console.log("\n🚀 TO TEST COMPONENTS:");
|
||||||
console.log('Visit: http://localhost:4321/blog/embed-demo');
|
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("If that 404s, the demo post needs to be added to blogPosts.ts");
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
Page as PDFPage,
|
Page as PDFPage,
|
||||||
Text as PDFText,
|
Text as PDFText,
|
||||||
View as PDFView,
|
View as PDFView,
|
||||||
StyleSheet as PDFStyleSheet,
|
StyleSheet as PDFStyleSheet,
|
||||||
} from '@react-pdf/renderer';
|
} from "@react-pdf/renderer";
|
||||||
import { pdfStyles, Header, Footer, FoldingMarks, DocumentTitle } from './pdf/SharedUI';
|
import {
|
||||||
import { SimpleLayout } from './pdf/SimpleLayout';
|
pdfStyles,
|
||||||
|
Header,
|
||||||
|
Footer,
|
||||||
|
FoldingMarks,
|
||||||
|
DocumentTitle,
|
||||||
|
} from "./pdf/SharedUI";
|
||||||
|
import { SimpleLayout } from "./pdf/SimpleLayout";
|
||||||
|
|
||||||
const localStyles = PDFStyleSheet.create({
|
const localStyles = PDFStyleSheet.create({
|
||||||
sectionContainer: {
|
sectionContainer: {
|
||||||
@@ -18,126 +24,201 @@ const localStyles = PDFStyleSheet.create({
|
|||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
labelRow: {
|
labelRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'baseline',
|
alignItems: "baseline",
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
},
|
},
|
||||||
monoNumber: {
|
monoNumber: {
|
||||||
fontSize: 7,
|
fontSize: 7,
|
||||||
fontWeight: 'bold',
|
fontWeight: "bold",
|
||||||
color: '#94a3b8',
|
color: "#94a3b8",
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
width: 25,
|
width: 25,
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontWeight: 'bold',
|
fontWeight: "bold",
|
||||||
color: '#000000',
|
color: "#000000",
|
||||||
textTransform: 'uppercase',
|
textTransform: "uppercase",
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
officialText: {
|
officialText: {
|
||||||
fontSize: 8,
|
fontSize: 8,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
color: '#334155',
|
color: "#334155",
|
||||||
textAlign: 'justify',
|
textAlign: "justify",
|
||||||
paddingLeft: 25,
|
paddingLeft: 25,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const AGBSection = ({ index, title, children }: { index: string; title: string; children: React.ReactNode }) => (
|
const AGBSection = ({
|
||||||
<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>
|
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 {
|
interface AgbsPDFProps {
|
||||||
state: any;
|
|
||||||
headerIcon?: string;
|
headerIcon?: string;
|
||||||
footerLogo?: string;
|
footerLogo?: string;
|
||||||
mode?: 'estimation' | 'full';
|
mode?: "estimation" | "full";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AgbsPDF = ({ state, headerIcon, footerLogo, mode = 'full' }: AgbsPDFProps) => {
|
export const AgbsPDF = ({
|
||||||
const date = new Date().toLocaleDateString('de-DE', {
|
headerIcon,
|
||||||
year: 'numeric',
|
footerLogo,
|
||||||
month: 'long',
|
mode = "full",
|
||||||
day: 'numeric',
|
}: AgbsPDFProps) => {
|
||||||
|
const date = new Date().toLocaleDateString("de-DE", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
const companyData = {
|
const companyData = {
|
||||||
name: "Marc Mintel",
|
name: "Marc Mintel",
|
||||||
address1: "Georg-Meistermann-Straße 7",
|
address1: "Georg-Meistermann-Straße 7",
|
||||||
address2: "54586 Schüller",
|
address2: "54586 Schüller",
|
||||||
ustId: "DE367588065"
|
ustId: "DE367588065",
|
||||||
};
|
};
|
||||||
|
|
||||||
const bankData = {
|
const bankData = {
|
||||||
name: "N26",
|
name: "N26",
|
||||||
bic: "NTSBDEB1XXX",
|
bic: "NTSBDEB1XXX",
|
||||||
iban: "DE50 1001 1001 2620 4328 65"
|
iban: "DE50 1001 1001 2620 4328 65",
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<DocumentTitle title="Allgemeine Geschäftsbedingungen" subLines={[`Stand: ${date}`]} />
|
<DocumentTitle
|
||||||
|
title="Allgemeine Geschäftsbedingungen"
|
||||||
|
subLines={[`Stand: ${date}`]}
|
||||||
|
/>
|
||||||
<PDFView style={localStyles.sectionContainer}>
|
<PDFView style={localStyles.sectionContainer}>
|
||||||
<AGBSection index="01" title="Geltungsbereich">
|
<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.
|
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>
|
||||||
|
|
||||||
<AGBSection index="02" title="Vertragsgegenstand">
|
<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.
|
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>
|
||||||
|
|
||||||
<AGBSection index="03" title="Mitwirkungspflichten des Auftraggebers">
|
<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.
|
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>
|
||||||
|
|
||||||
<AGBSection index="04" title="Ausführungs- und Bearbeitungszeiten">
|
<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.
|
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>
|
||||||
|
|
||||||
<AGBSection index="05" title="Abnahme">
|
<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.
|
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>
|
||||||
|
|
||||||
<AGBSection index="06" title="Haftung">
|
<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.
|
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>
|
||||||
|
|
||||||
<AGBSection index="07" title="Verfügbarkeit & Betrieb">
|
<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.
|
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>
|
||||||
|
|
||||||
<AGBSection index="07a" title="Betriebs- und Pflegeleistung">
|
<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.
|
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>
|
||||||
|
|
||||||
<AGBSection index="08" title="Drittanbieter & externe Systeme">
|
<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.
|
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>
|
||||||
|
|
||||||
<AGBSection index="09" title="Inhalte & Rechtliches">
|
<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.
|
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>
|
||||||
|
|
||||||
<AGBSection index="10" title="Vergütung & Zahlungsverzug">
|
<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.
|
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>
|
||||||
|
|
||||||
<AGBSection index="11" title="Kündigung laufender Leistungen">
|
<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.
|
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>
|
||||||
|
|
||||||
<AGBSection index="12" title="Schlussbestimmungen">
|
<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.
|
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>
|
</AGBSection>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mode === 'full') {
|
if (mode === "full") {
|
||||||
return (
|
return (
|
||||||
<SimpleLayout companyData={companyData} bankData={bankData} footerLogo={footerLogo} icon={headerIcon} pageNumber="10" showPageNumber={false}>
|
<SimpleLayout
|
||||||
|
companyData={companyData}
|
||||||
|
bankData={bankData}
|
||||||
|
footerLogo={footerLogo}
|
||||||
|
icon={headerIcon}
|
||||||
|
pageNumber="10"
|
||||||
|
showPageNumber={false}
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</SimpleLayout>
|
</SimpleLayout>
|
||||||
);
|
);
|
||||||
@@ -148,7 +229,13 @@ export const AgbsPDF = ({ state, headerIcon, footerLogo, mode = 'full' }: AgbsPD
|
|||||||
<FoldingMarks />
|
<FoldingMarks />
|
||||||
<Header icon={headerIcon} showAddress={false} />
|
<Header icon={headerIcon} showAddress={false} />
|
||||||
{content}
|
{content}
|
||||||
<Footer logo={footerLogo} companyData={companyData} bankData={bankData} showDetails={false} showPageNumber={false} />
|
<Footer
|
||||||
|
logo={footerLogo}
|
||||||
|
companyData={companyData}
|
||||||
|
bankData={bankData}
|
||||||
|
showDetails={false}
|
||||||
|
showPageNumber={false}
|
||||||
|
/>
|
||||||
</PDFPage>
|
</PDFPage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ export const CombinedQuotePDF = ({
|
|||||||
{showAgbs && (
|
{showAgbs && (
|
||||||
<AgbsPDF
|
<AgbsPDF
|
||||||
mode={mode}
|
mode={mode}
|
||||||
state={estimationProps.state}
|
|
||||||
headerIcon={estimationProps.headerIcon}
|
headerIcon={estimationProps.headerIcon}
|
||||||
footerLogo={estimationProps.footerLogo}
|
footerLogo={estimationProps.footerLogo}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,34 +1,43 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
import { useState, useMemo, useEffect, useRef } from "react";
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { ChevronRight, ChevronLeft, Send, Check, Sparkles, Info, ArrowRight } from 'lucide-react';
|
import {
|
||||||
import * as QRCode from 'qrcode';
|
ChevronRight,
|
||||||
import * as confetti from 'canvas-confetti';
|
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 { FormState, Step } from "./ContactForm/types";
|
||||||
import { PRICING, initialState } from './ContactForm/constants';
|
import { PRICING, initialState } from "./ContactForm/constants";
|
||||||
import { calculateTotals } from '../logic/pricing/calculator';
|
import { calculateTotals } from "../logic/pricing/calculator";
|
||||||
import { PriceCalculation } from './ContactForm/components/PriceCalculation';
|
import { PriceCalculation } from "./ContactForm/components/PriceCalculation";
|
||||||
import { ShareModal } from './ShareModal';
|
import { ShareModal } from "./ShareModal";
|
||||||
|
|
||||||
// Steps
|
// Steps
|
||||||
import { TypeStep } from './ContactForm/steps/TypeStep';
|
import { TypeStep } from "./ContactForm/steps/TypeStep";
|
||||||
import { CompanyStep } from './ContactForm/steps/CompanyStep';
|
import { CompanyStep } from "./ContactForm/steps/CompanyStep";
|
||||||
import { PresenceStep } from './ContactForm/steps/PresenceStep';
|
import { PresenceStep } from "./ContactForm/steps/PresenceStep";
|
||||||
import { BaseStep } from './ContactForm/steps/BaseStep';
|
import { BaseStep } from "./ContactForm/steps/BaseStep";
|
||||||
import { FeaturesStep } from './ContactForm/steps/FeaturesStep';
|
/* eslint-disable no-unused-vars */
|
||||||
import { DesignStep } from './ContactForm/steps/DesignStep';
|
|
||||||
import { AssetsStep } from './ContactForm/steps/AssetsStep';
|
import { FeaturesStep } from "./ContactForm/steps/FeaturesStep";
|
||||||
import { FunctionsStep } from './ContactForm/steps/FunctionsStep';
|
import { DesignStep } from "./ContactForm/steps/DesignStep";
|
||||||
import { ApiStep } from './ContactForm/steps/ApiStep';
|
import { AssetsStep } from "./ContactForm/steps/AssetsStep";
|
||||||
import { ContentStep } from './ContactForm/steps/ContentStep';
|
import { FunctionsStep } from "./ContactForm/steps/FunctionsStep";
|
||||||
import { LanguageStep } from './ContactForm/steps/LanguageStep';
|
import { ApiStep } from "./ContactForm/steps/ApiStep";
|
||||||
import { TimelineStep } from './ContactForm/steps/TimelineStep';
|
import { ContentStep } from "./ContactForm/steps/ContentStep";
|
||||||
import { ContactStep } from './ContactForm/steps/ContactStep';
|
import { LanguageStep } from "./ContactForm/steps/LanguageStep";
|
||||||
import { WebAppStep } from './ContactForm/steps/WebAppStep';
|
import { TimelineStep } from "./ContactForm/steps/TimelineStep";
|
||||||
|
import { ContactStep } from "./ContactForm/steps/ContactStep";
|
||||||
|
import { WebAppStep } from "./ContactForm/steps/WebAppStep";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ConceptTarget,
|
ConceptTarget,
|
||||||
@@ -39,28 +48,42 @@ import {
|
|||||||
ConceptCode,
|
ConceptCode,
|
||||||
ConceptAutomation,
|
ConceptAutomation,
|
||||||
ConceptPrice,
|
ConceptPrice,
|
||||||
HeroArchitecture
|
HeroArchitecture,
|
||||||
} from './Landing/ConceptIllustrations';
|
} from "./Landing/ConceptIllustrations";
|
||||||
|
|
||||||
export interface ContactFormProps {
|
export interface ContactFormProps {
|
||||||
initialStepIndex?: number;
|
initialStepIndex?: number;
|
||||||
initialState?: FormState;
|
initialState?: FormState;
|
||||||
onStepChange?: (index: number) => void;
|
onStepChange?: (_index: number) => void;
|
||||||
onStateChange?: (state: FormState) => 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
|
// Use a safe version of useRouter/useSearchParams that doesn't crash if not in a router context
|
||||||
let router: any = null;
|
let router: any = null;
|
||||||
let searchParams: any = null;
|
let searchParams: any = null;
|
||||||
try { router = useRouter(); } catch (e) { /* ignore */ }
|
try {
|
||||||
try { searchParams = useSearchParams(); } catch (e) { /* ignore */ }
|
router = useRouter();
|
||||||
|
} catch (_e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
searchParams = useSearchParams();
|
||||||
|
} catch (_e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
const [internalStepIndex, setInternalStepIndex] = useState(0);
|
const [internalStepIndex, setInternalStepIndex] = useState(0);
|
||||||
const [internalState, setInternalState] = useState<FormState>(initialState);
|
const [internalState, setInternalState] = useState<FormState>(initialState);
|
||||||
|
|
||||||
// Sync with props if provided
|
// Sync with props if provided
|
||||||
const stepIndex = initialStepIndex !== undefined ? initialStepIndex : internalStepIndex;
|
const stepIndex =
|
||||||
|
initialStepIndex !== undefined ? initialStepIndex : internalStepIndex;
|
||||||
const state = propState !== undefined ? propState : internalState;
|
const state = propState !== undefined ? propState : internalState;
|
||||||
|
|
||||||
const setStepIndex = (val: number) => {
|
const setStepIndex = (val: number) => {
|
||||||
@@ -69,8 +92,8 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setState = (val: any) => {
|
const setState = (val: any) => {
|
||||||
if (typeof val === 'function') {
|
if (typeof val === "function") {
|
||||||
setInternalState(prev => {
|
setInternalState((prev) => {
|
||||||
const next = val(prev);
|
const next = val(prev);
|
||||||
onStateChange?.(next);
|
onStateChange?.(next);
|
||||||
return next;
|
return next;
|
||||||
@@ -82,13 +105,14 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
const [qrCodeData, setQrCodeData] = useState<string>('');
|
const [qrCodeData, setQrCodeData] = useState<string>("");
|
||||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
||||||
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
||||||
const [isSticky, setIsSticky] = useState(false);
|
const [isSticky, setIsSticky] = useState(false);
|
||||||
const formContainerRef = useRef<HTMLDivElement>(null);
|
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);
|
const [isClient, setIsClient] = useState(isRemotion);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -99,9 +123,9 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
setIsSticky(rect.top <= 80);
|
setIsSticky(rect.top <= 80);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('scroll', handleScroll);
|
window.addEventListener("scroll", handleScroll);
|
||||||
handleScroll();
|
handleScroll();
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
}, [isRemotion]);
|
}, [isRemotion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -111,10 +135,10 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
// URL Binding
|
// URL Binding
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchParams) return;
|
if (!searchParams) return;
|
||||||
const step = searchParams.get('step');
|
const step = searchParams.get("step");
|
||||||
if (step) setStepIndex(parseInt(step));
|
if (step) setStepIndex(parseInt(step));
|
||||||
|
|
||||||
const config = searchParams.get('config');
|
const config = searchParams.get("config");
|
||||||
if (config) {
|
if (config) {
|
||||||
try {
|
try {
|
||||||
const decoded = JSON.parse(decodeURIComponent(escape(atob(config))));
|
const decoded = JSON.parse(decodeURIComponent(escape(atob(config))));
|
||||||
@@ -126,9 +150,9 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const currentUrl = useMemo(() => {
|
const currentUrl = useMemo(() => {
|
||||||
if (!isClient) return '';
|
if (!isClient) return "";
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('step', stepIndex.toString());
|
params.set("step", stepIndex.toString());
|
||||||
|
|
||||||
const configData = {
|
const configData = {
|
||||||
projectType: state.projectType,
|
projectType: state.projectType,
|
||||||
@@ -166,11 +190,13 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
platformType: state.platformType,
|
platformType: state.platformType,
|
||||||
dontKnows: state.dontKnows,
|
dontKnows: state.dontKnows,
|
||||||
visualStaging: state.visualStaging,
|
visualStaging: state.visualStaging,
|
||||||
complexInteractions: state.complexInteractions
|
complexInteractions: state.complexInteractions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const stateString = btoa(unescape(encodeURIComponent(JSON.stringify(configData))));
|
const stateString = btoa(
|
||||||
params.set('config', stateString);
|
unescape(encodeURIComponent(JSON.stringify(configData))),
|
||||||
|
);
|
||||||
|
params.set("config", stateString);
|
||||||
|
|
||||||
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
|
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
|
||||||
}, [state, stepIndex, isClient]);
|
}, [state, stepIndex, isClient]);
|
||||||
@@ -179,7 +205,9 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
if (isRemotion) return;
|
if (isRemotion) return;
|
||||||
if (currentUrl && router) {
|
if (currentUrl && router) {
|
||||||
router.replace(currentUrl, { scroll: false });
|
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]);
|
}, [currentUrl, router, isRemotion]);
|
||||||
|
|
||||||
@@ -187,14 +215,15 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
return calculateTotals(state, PRICING);
|
return calculateTotals(state, PRICING);
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
const { totalPrice, monthlyPrice, totalPagesCount } = totals;
|
// Destructuring moved to PriceCalculation if only used there
|
||||||
|
// const { totalPrice, monthlyPrice, totalPagesCount } = totals;
|
||||||
|
|
||||||
const updateState = (updates: Partial<FormState>) => {
|
const updateState = (updates: Partial<FormState>) => {
|
||||||
setState((s: FormState) => ({ ...s, ...updates }));
|
setState((s: FormState) => ({ ...s, ...updates }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleItem = (list: string[], id: string) => {
|
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 = () => {
|
const scrollToTop = () => {
|
||||||
@@ -208,7 +237,7 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
|
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: offsetPosition,
|
top: offsetPosition,
|
||||||
behavior: 'smooth'
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -228,44 +257,136 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
};
|
};
|
||||||
|
|
||||||
const steps: Step[] = [
|
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: "type",
|
||||||
{ id: 'presence', title: 'Präsenz', description: 'Bestehende Kanäle von {company}.', illustration: <ConceptSystem className="w-full h-full" />, chapter: 'strategy' },
|
title: "Das Ziel",
|
||||||
{ id: 'features', title: 'Die Systeme', description: 'Welche inhaltlichen Bereiche planen wir für {company}?', illustration: <ConceptPrototyping className="w-full h-full" />, chapter: 'scope' },
|
description: "Was möchten Sie realisieren?",
|
||||||
{ id: 'base', title: 'Die Seiten', description: 'Welche Seiten benötigen wir?', illustration: <ConceptWebsite className="w-full h-full" />, chapter: 'scope' },
|
illustration: <ConceptTarget className="w-full h-full" />,
|
||||||
{ 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' },
|
chapter: "strategy",
|
||||||
{ 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: "company",
|
||||||
{ id: 'content', title: 'Die Pflege', description: 'Wer kümmert sich um die Daten?', illustration: <ConceptPrice className="w-full h-full" />, chapter: 'tech' },
|
title: "Unternehmen",
|
||||||
{ id: 'language', title: 'Sprachen', description: 'Globale Reichweite planen.', illustration: <ConceptCommunication className="w-full h-full" />, chapter: 'tech' },
|
description: "Wer sind Sie?",
|
||||||
{ id: 'timeline', title: 'Zeitplan', description: 'Wann soll das Projekt live gehen?', illustration: <HeroArchitecture className="w-full h-full" />, chapter: 'final' },
|
illustration: <ConceptCommunication className="w-full h-full" />,
|
||||||
{ id: 'contact', title: 'Abschluss', description: 'Erzählen Sie mir mehr über Ihr Vorhaben.', illustration: <ConceptCommunication className="w-full h-full" />, chapter: 'final' },
|
chapter: "strategy",
|
||||||
{ id: 'webapp', title: 'Web App Details', description: 'Spezifische Anforderungen für {company}.', illustration: <ConceptSystem className="w-full h-full" />, chapter: 'scope' },
|
},
|
||||||
|
{
|
||||||
|
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 = [
|
const chapters = [
|
||||||
{ id: 'strategy', title: 'Strategie' },
|
{ id: "strategy", title: "Strategie" },
|
||||||
{ id: 'scope', title: 'Umfang' },
|
{ id: "scope", title: "Umfang" },
|
||||||
{ id: 'creative', title: 'Design' },
|
{ id: "creative", title: "Design" },
|
||||||
{ id: 'tech', title: 'Technik' },
|
{ id: "tech", title: "Technik" },
|
||||||
{ id: 'final', title: 'Start' },
|
{ id: "final", title: "Start" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeSteps = useMemo(() => {
|
const activeSteps = useMemo(() => {
|
||||||
if (state.projectType === 'website') {
|
if (state.projectType === "website") {
|
||||||
return steps.filter(s => s.id !== 'webapp');
|
return steps.filter((s) => s.id !== "webapp");
|
||||||
}
|
}
|
||||||
// Web App flow
|
// Web App flow
|
||||||
return [
|
return [
|
||||||
steps.find(s => s.id === 'type')!,
|
steps.find((s) => s.id === "type")!,
|
||||||
steps.find(s => s.id === 'company')!,
|
steps.find((s) => s.id === "company")!,
|
||||||
steps.find(s => s.id === 'presence')!,
|
steps.find((s) => s.id === "presence")!,
|
||||||
steps.find(s => s.id === 'webapp')!,
|
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 === "functions")!,
|
||||||
steps.find(s => s.id === 'timeline')!,
|
title: "Funktionen",
|
||||||
steps.find(s => s.id === 'contact')!,
|
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]);
|
}, [state.projectType, state.companyName]);
|
||||||
|
|
||||||
@@ -276,35 +397,72 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
const renderStepContent = () => {
|
const renderStepContent = () => {
|
||||||
const currentStep = activeSteps[stepIndex];
|
const currentStep = activeSteps[stepIndex];
|
||||||
switch (currentStep.id) {
|
switch (currentStep.id) {
|
||||||
case 'type':
|
case "type":
|
||||||
return <TypeStep state={state} updateState={updateState} />;
|
return <TypeStep state={state} updateState={updateState} />;
|
||||||
case 'company':
|
case "company":
|
||||||
return <CompanyStep state={state} updateState={updateState} />;
|
return <CompanyStep state={state} updateState={updateState} />;
|
||||||
case 'presence':
|
case "presence":
|
||||||
return <PresenceStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
return (
|
||||||
case 'base':
|
<PresenceStep
|
||||||
return <BaseStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
state={state}
|
||||||
case 'features':
|
updateState={updateState}
|
||||||
return <FeaturesStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
toggleItem={toggleItem}
|
||||||
case 'design':
|
/>
|
||||||
|
);
|
||||||
|
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} />;
|
return <DesignStep state={state} updateState={updateState} />;
|
||||||
case 'assets':
|
case "assets":
|
||||||
return <AssetsStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
return (
|
||||||
case 'functions':
|
<AssetsStep
|
||||||
return <FunctionsStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
state={state}
|
||||||
case 'api':
|
updateState={updateState}
|
||||||
return <ApiStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
toggleItem={toggleItem}
|
||||||
case 'content':
|
/>
|
||||||
|
);
|
||||||
|
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} />;
|
return <ContentStep state={state} updateState={updateState} />;
|
||||||
case 'language':
|
case "language":
|
||||||
return <LanguageStep state={state} updateState={updateState} />;
|
return <LanguageStep state={state} updateState={updateState} />;
|
||||||
case 'timeline':
|
case "timeline":
|
||||||
return <TimelineStep state={state} updateState={updateState} />;
|
return <TimelineStep state={state} updateState={updateState} />;
|
||||||
case 'contact':
|
case "contact":
|
||||||
return <ContactStep state={state} updateState={updateState} />;
|
return <ContactStep state={state} updateState={updateState} />;
|
||||||
case 'webapp':
|
case "webapp":
|
||||||
return <WebAppStep state={state} updateState={updateState} />;
|
return <WebAppStep state={state} updateState={updateState} />;
|
||||||
default: return null;
|
default:
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -329,9 +487,11 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
const animationEnd = Date.now() + duration;
|
const animationEnd = Date.now() + duration;
|
||||||
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
|
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 interval: any = !isRemotion
|
||||||
|
? setInterval(function () {
|
||||||
const timeLeft = animationEnd - Date.now();
|
const timeLeft = animationEnd - Date.now();
|
||||||
|
|
||||||
if (timeLeft <= 0) {
|
if (timeLeft <= 0) {
|
||||||
@@ -339,9 +499,18 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
}
|
}
|
||||||
|
|
||||||
const particleCount = 50 * (timeLeft / duration);
|
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)({
|
||||||
(confetti as any)({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } });
|
...defaults,
|
||||||
}, 250) : null;
|
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);
|
setIsSubmitted(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -355,21 +524,48 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
|
|
||||||
if (isSubmitted) {
|
if (isSubmitted) {
|
||||||
return (
|
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">
|
<motion.div
|
||||||
<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>
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
<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>
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
<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>
|
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>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="lg:col-span-8 space-y-12">
|
||||||
<div
|
<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 flex-row items-center justify-between gap-8">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -377,7 +573,7 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
scale: isSticky ? 0.7 : 1,
|
scale: isSticky ? 0.7 : 1,
|
||||||
width: isSticky ? 80 : 128,
|
width: isSticky ? 80 : 128,
|
||||||
height: 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"
|
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 }}
|
initial={{ scale: 0, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
exit={{ scale: 0, opacity: 0 }}
|
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"
|
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}
|
{stepIndex + 1}
|
||||||
@@ -400,7 +600,11 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
<div className="space-y-1 min-w-0">
|
<div className="space-y-1 min-w-0">
|
||||||
<motion.div
|
<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"
|
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">
|
<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.div>
|
||||||
<motion.h3
|
<motion.h3
|
||||||
animate={{
|
animate={{
|
||||||
fontSize: isSticky ? '1.5rem' : '2.25rem',
|
fontSize: isSticky ? "1.5rem" : "2.25rem",
|
||||||
lineHeight: isSticky ? '2rem' : '2.5rem',
|
lineHeight: isSticky ? "2rem" : "2.5rem",
|
||||||
color: isSticky ? '#0f172a' : '#0f172a'
|
color: isSticky ? "#0f172a" : "#0f172a",
|
||||||
}}
|
}}
|
||||||
className="font-black tracking-tight truncate"
|
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.h3>
|
||||||
<motion.p
|
<motion.p
|
||||||
animate={{
|
animate={{
|
||||||
fontSize: isSticky ? '0.875rem' : '1.125rem',
|
fontSize: isSticky ? "0.875rem" : "1.125rem",
|
||||||
lineHeight: isSticky ? '1.25rem' : '1.75rem'
|
lineHeight: isSticky ? "1.25rem" : "1.75rem",
|
||||||
}}
|
}}
|
||||||
className="text-slate-500 leading-relaxed max-w-2xl truncate overflow-hidden"
|
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>
|
</motion.p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -433,15 +643,17 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
{stepIndex > 0 ? (
|
{stepIndex > 0 ? (
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ x: -3, backgroundColor: '#f8fafc' }}
|
whileHover={{ x: -3, backgroundColor: "#f8fafc" }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={prevStep}
|
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
|
<ChevronLeft size={isSticky ? 16 : 20} /> Zurück
|
||||||
</motion.button>
|
</motion.button>
|
||||||
) : <div className={isSticky ? 'w-0' : 'w-32'} />}
|
) : (
|
||||||
|
<div className={isSticky ? "w-0" : "w-32"} />
|
||||||
|
)}
|
||||||
|
|
||||||
{stepIndex < activeSteps.length - 1 ? (
|
{stepIndex < activeSteps.length - 1 ? (
|
||||||
<motion.button
|
<motion.button
|
||||||
@@ -450,9 +662,13 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={nextStep}
|
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>
|
||||||
) : (
|
) : (
|
||||||
<motion.button
|
<motion.button
|
||||||
@@ -461,16 +677,22 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
type="submit"
|
type="submit"
|
||||||
form="contact-form"
|
form="contact-form"
|
||||||
disabled={!state.email || !state.name}
|
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>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<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) => (
|
{activeSteps.map((step, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
@@ -484,19 +706,36 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
setStepIndex(i);
|
setStepIndex(i);
|
||||||
setTimeout(scrollToTop, 50);
|
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' :
|
className={`w-full h-full rounded-full transition-all duration-700 ${
|
||||||
i < stepIndex ? 'bg-slate-400' : 'bg-slate-100'
|
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`}
|
} cursor-pointer focus:outline-none p-0 border-none relative group`}
|
||||||
>
|
>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{hoveredStep === i && (
|
{hoveredStep === i && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 5, x: "-50%", scale: 0.9 }}
|
initial={{
|
||||||
animate={{ opacity: 1, y: isSticky ? -35 : -40, x: "-50%", scale: 1 }}
|
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 }}
|
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"
|
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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -507,18 +746,24 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
|
|
||||||
{!isSticky && (
|
{!isSticky && (
|
||||||
<div className="flex justify-between mt-4 px-1">
|
<div className="flex justify-between mt-4 px-1">
|
||||||
{chapters.map((chapter, idx) => {
|
{chapters.map((chapter, _idx) => {
|
||||||
const chapterSteps = activeSteps.filter(s => s.chapter === chapter.id);
|
const chapterSteps = activeSteps.filter(
|
||||||
|
(s) => s.chapter === chapter.id,
|
||||||
|
);
|
||||||
if (chapterSteps.length === 0) return null;
|
if (chapterSteps.length === 0) return null;
|
||||||
|
|
||||||
const firstStepIdx = activeSteps.indexOf(chapterSteps[0]);
|
const firstStepIdx = activeSteps.indexOf(chapterSteps[0]);
|
||||||
const lastStepIdx = activeSteps.indexOf(chapterSteps[chapterSteps.length - 1]);
|
const lastStepIdx = activeSteps.indexOf(
|
||||||
const isActive = stepIndex >= firstStepIdx && stepIndex <= lastStepIdx;
|
chapterSteps[chapterSteps.length - 1],
|
||||||
|
);
|
||||||
|
const isActive =
|
||||||
|
stepIndex >= firstStepIdx && stepIndex <= lastStepIdx;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={chapter.id}
|
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}
|
{chapter.title}
|
||||||
@@ -531,8 +776,11 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={activeSteps[stepIndex].id}
|
key={activeSteps[stepIndex].id}
|
||||||
@@ -559,12 +807,18 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
<Info size={28} />
|
<Info size={28} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 relative z-10">
|
<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">
|
<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 === 0 &&
|
||||||
{stepIndex === 1 && "Ihr Unternehmen und dessen Größe helfen mir, die Skalierbarkeit und die notwendige Infrastruktur von Beginn an richtig zu dimensionieren."}
|
"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 === 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 === 1 &&
|
||||||
{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."}
|
"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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -574,7 +828,7 @@ export function ContactForm({ initialStepIndex, initialState: propState, onStepC
|
|||||||
state={state}
|
state={state}
|
||||||
totals={totals}
|
totals={totals}
|
||||||
isClient={isClient}
|
isClient={isClient}
|
||||||
qrCodeData={qrCodeData}
|
_qrCodeData={qrCodeData}
|
||||||
onShare={handleShare}
|
onShare={handleShare}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import * as React from "react";
|
|||||||
import { FormState, Totals } from "../types";
|
import { FormState, Totals } from "../types";
|
||||||
import { PRICING } from "../constants";
|
import { PRICING } from "../constants";
|
||||||
import { AnimatedNumber } from "./AnimatedNumber";
|
import { AnimatedNumber } from "./AnimatedNumber";
|
||||||
import {
|
/* eslint-disable no-unused-vars */
|
||||||
ConceptPrice,
|
|
||||||
ConceptAutomation,
|
import { ConceptAutomation } from "../../Landing/ConceptIllustrations";
|
||||||
} from "../../Landing/ConceptIllustrations";
|
import { Download, Share2, RefreshCw } from "lucide-react";
|
||||||
import { Info, Download, Share2, RefreshCw } from "lucide-react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
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
|
// EstimationPDF will be imported dynamically where used or inside the and client-side block
|
||||||
import IconWhite from "../../../assets/logo/Icon White Transparent.png";
|
import IconWhite from "../../../assets/logo/Icon White Transparent.png";
|
||||||
import LogoBlack from "../../../assets/logo/Logo Black Transparent.png";
|
import LogoBlack from "../../../assets/logo/Logo Black Transparent.png";
|
||||||
@@ -21,7 +19,7 @@ interface PriceCalculationProps {
|
|||||||
state: FormState;
|
state: FormState;
|
||||||
totals: Totals;
|
totals: Totals;
|
||||||
isClient: boolean;
|
isClient: boolean;
|
||||||
qrCodeData: string;
|
_qrCodeData: string;
|
||||||
onShare?: () => void;
|
onShare?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +27,7 @@ export function PriceCalculation({
|
|||||||
state,
|
state,
|
||||||
totals,
|
totals,
|
||||||
isClient,
|
isClient,
|
||||||
qrCodeData,
|
_qrCodeData,
|
||||||
onShare,
|
onShare,
|
||||||
}: PriceCalculationProps) {
|
}: PriceCalculationProps) {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState } from './types';
|
import { type FormState as _FormState } from "./types";
|
||||||
import {
|
import {
|
||||||
PRICING as LOGIC_PRICING,
|
PRICING as LOGIC_PRICING,
|
||||||
PAGE_SAMPLES as LOGIC_PAGE_SAMPLES,
|
PAGE_SAMPLES as LOGIC_PAGE_SAMPLES,
|
||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
API_LABELS as LOGIC_API_LABELS,
|
API_LABELS as LOGIC_API_LABELS,
|
||||||
SOCIAL_LABELS as LOGIC_SOCIAL_LABELS,
|
SOCIAL_LABELS as LOGIC_SOCIAL_LABELS,
|
||||||
initialState as LOGIC_INITIAL_STATE,
|
initialState as LOGIC_INITIAL_STATE,
|
||||||
DESIGN_OPTIONS
|
DESIGN_OPTIONS,
|
||||||
} from '../../logic/pricing';
|
} from "../../logic/pricing";
|
||||||
|
|
||||||
export const PRICING = LOGIC_PRICING;
|
export const PRICING = LOGIC_PRICING;
|
||||||
export const PAGE_SAMPLES = LOGIC_PAGE_SAMPLES;
|
export const PAGE_SAMPLES = LOGIC_PAGE_SAMPLES;
|
||||||
@@ -40,15 +40,50 @@ export const initialState = LOGIC_INITIAL_STATE;
|
|||||||
const VIBE_ILLUSTRATIONS: Record<string, React.ReactNode> = {
|
const VIBE_ILLUSTRATIONS: Record<string, React.ReactNode> = {
|
||||||
minimal: (
|
minimal: (
|
||||||
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
|
<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
|
||||||
<rect x="10" y="20" width="50" height="2" rx="1" className="fill-current" />
|
x="10"
|
||||||
<rect x="10" y="40" width="30" height="10" rx="1" className="fill-current" />
|
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>
|
</svg>
|
||||||
),
|
),
|
||||||
bold: (
|
bold: (
|
||||||
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
|
<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
|
||||||
<rect x="10" y="35" width="80" height="15" rx="2" className="fill-current" />
|
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>
|
</svg>
|
||||||
),
|
),
|
||||||
nature: (
|
nature: (
|
||||||
@@ -59,14 +94,29 @@ const VIBE_ILLUSTRATIONS: Record<string, React.ReactNode> = {
|
|||||||
),
|
),
|
||||||
tech: (
|
tech: (
|
||||||
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
|
<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
|
||||||
<path d="M10 30 L90 30" stroke="currentColor" strokeWidth="1" strokeDasharray="4 2" />
|
d="M10 10 L90 10 L90 50 L10 50 Z"
|
||||||
<path d="M50 10 L50 50" stroke="currentColor" strokeWidth="1" strokeDasharray="4 2" />
|
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>
|
</svg>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DESIGN_VIBES = DESIGN_OPTIONS.map(opt => ({
|
export const DESIGN_VIBES = DESIGN_OPTIONS.map((opt) => ({
|
||||||
...opt,
|
...opt,
|
||||||
illustration: VIBE_ILLUSTRATIONS[opt.id]
|
illustration: VIBE_ILLUSTRATIONS[opt.id],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState } from '../types';
|
import { FormState } from "../types";
|
||||||
import { Checkbox } from '../components/Checkbox';
|
import { Checkbox } from "../components/Checkbox";
|
||||||
import { RepeatableList } from '../components/RepeatableList';
|
import { RepeatableList } from "../components/RepeatableList";
|
||||||
import { Share2, ListPlus } from 'lucide-react';
|
import { Share2, ListPlus } from "lucide-react";
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from "framer-motion";
|
||||||
import { Reveal } from '../../Reveal';
|
import { Reveal } from "../../Reveal";
|
||||||
|
|
||||||
interface ApiStepProps {
|
interface ApiStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
updateState: (updates: Partial<FormState>) => void;
|
updateState: (_updates: Partial<FormState>) => void;
|
||||||
toggleItem: (list: string[], id: string) => string[];
|
toggleItem: (_list: string[], _id: string) => string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
|
export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
|
||||||
const isWebApp = state.projectType === 'web-app';
|
const isWebApp = state.projectType === "web-app";
|
||||||
|
|
||||||
const toggleDontKnow = (id: string) => {
|
const toggleDontKnow = (id: string) => {
|
||||||
const current = state.dontKnows || [];
|
const current = state.dontKnows || [];
|
||||||
if (current.includes(id)) {
|
if (current.includes(id)) {
|
||||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||||
} else {
|
} else {
|
||||||
updateState({ dontKnows: [...current, id] });
|
updateState({ dontKnows: [...current, id] });
|
||||||
}
|
}
|
||||||
@@ -37,18 +37,24 @@ export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h4 className="text-2xl font-bold text-slate-900">
|
<h4 className="text-2xl font-bold text-slate-900">
|
||||||
{isWebApp ? 'Integrationen & Datenquellen' : 'Schnittstellen (API)'}
|
{isWebApp
|
||||||
|
? "Integrationen & Datenquellen"
|
||||||
|
: "Schnittstellen (API)"}
|
||||||
</h4>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
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 ${
|
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
|
Ich weiß es nicht
|
||||||
@@ -56,16 +62,36 @@ export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-lg text-slate-500 leading-relaxed ml-2">
|
<p className="text-lg text-slate-500 leading-relaxed ml-2">
|
||||||
{isWebApp
|
{isWebApp
|
||||||
? 'Mit welchen Systemen soll die Web App kommunizieren?'
|
? "Mit welchen Systemen soll die Web App kommunizieren?"
|
||||||
: 'Datenaustausch mit Drittsystemen zur Automatisierung.'}
|
: "Datenaustausch mit Drittsystemen zur Automatisierung."}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<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: "crm_erp",
|
||||||
{ id: 'marketing', label: 'Marketing', desc: 'Newsletter (Mailchimp), Social Media Sync.' },
|
label: "CRM / ERP",
|
||||||
{ id: 'ecommerce', label: 'E-Commerce', desc: 'Shopify, WooCommerce, Lagerbestand-Sync.' },
|
desc: "HubSpot, Salesforce, SAP, Xentral etc.",
|
||||||
{ id: 'maps', label: 'Google Maps / Places', desc: 'Standortsuche und Kartenintegration.' },
|
},
|
||||||
|
{
|
||||||
|
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) => (
|
].map((opt, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
@@ -74,9 +100,14 @@ export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
|
|||||||
transition={{ delay: index * 0.05 }}
|
transition={{ delay: index * 0.05 }}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={opt.label} desc={opt.desc}
|
label={opt.label}
|
||||||
|
desc={opt.desc}
|
||||||
checked={state.apiSystems.includes(opt.id)}
|
checked={state.apiSystems.includes(opt.id)}
|
||||||
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, opt.id) })}
|
onChange={() =>
|
||||||
|
updateState({
|
||||||
|
apiSystems: toggleItem(state.apiSystems, opt.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</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">
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||||
<ListPlus size={24} />
|
<ListPlus size={24} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<RepeatableList
|
<RepeatableList
|
||||||
items={state.otherTech}
|
items={state.otherTech}
|
||||||
@@ -106,6 +139,8 @@ export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
function updateTech(index: number) {
|
function updateTech(index: number) {
|
||||||
updateState({ otherTech: state.otherTech.filter((_, idx) => idx !== index) });
|
updateState({
|
||||||
|
otherTech: state.otherTech.filter((_, idx) => idx !== index),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState } from '../types';
|
import { FormState } from "../types";
|
||||||
import { ASSET_OPTIONS } from '../constants';
|
import { ASSET_OPTIONS } from "../constants";
|
||||||
import { Checkbox } from '../components/Checkbox';
|
import { Checkbox } from "../components/Checkbox";
|
||||||
import { RepeatableList } from '../components/RepeatableList';
|
import { RepeatableList } from "../components/RepeatableList";
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from "framer-motion";
|
||||||
import { Minus, Plus, Briefcase, ListPlus } from 'lucide-react';
|
import { Briefcase, ListPlus } from "lucide-react";
|
||||||
import { Reveal } from '../../Reveal';
|
import { Reveal } from "../../Reveal";
|
||||||
|
|
||||||
interface AssetsStepProps {
|
interface AssetsStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
updateState: (updates: Partial<FormState>) => void;
|
updateState: (_updates: Partial<FormState>) => void;
|
||||||
toggleItem: (list: string[], id: string) => string[];
|
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 toggleDontKnow = (id: string) => {
|
||||||
const current = state.dontKnows || [];
|
const current = state.dontKnows || [];
|
||||||
if (current.includes(id)) {
|
if (current.includes(id)) {
|
||||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||||
} else {
|
} else {
|
||||||
updateState({ dontKnows: [...current, id] });
|
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">
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||||
<Briefcase size={24} />
|
<Briefcase size={24} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
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 ${
|
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
|
Ich weiß es nicht
|
||||||
@@ -58,11 +66,17 @@ export function AssetsStep({ state, updateState, toggleItem }: AssetsStepProps)
|
|||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Checkbox
|
<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)}
|
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">
|
<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
|
Empfohlen
|
||||||
</div>
|
</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">
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||||
<ListPlus size={24} />
|
<ListPlus size={24} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<RepeatableList
|
<RepeatableList
|
||||||
items={state.otherAssets}
|
items={state.otherAssets}
|
||||||
onAdd={(v) => updateState({ otherAssets: [...state.otherAssets, v] })}
|
onAdd={(v) =>
|
||||||
onRemove={(i) => updateState({ otherAssets: state.otherAssets.filter((_, idx) => idx !== i) })}
|
updateState({ otherAssets: [...state.otherAssets, v] })
|
||||||
|
}
|
||||||
|
onRemove={(i) =>
|
||||||
|
updateState({
|
||||||
|
otherAssets: state.otherAssets.filter((_, idx) => idx !== i),
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="z.B. Lastenheft, Wireframes, Bilddatenbank..."
|
placeholder="z.B. Lastenheft, Wireframes, Bilddatenbank..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState } from '../types';
|
import { FormState } from "../types";
|
||||||
import { Checkbox } from '../components/Checkbox';
|
import { Checkbox } from "../components/Checkbox";
|
||||||
import { RepeatableList } from '../components/RepeatableList';
|
import { RepeatableList } from "../components/RepeatableList";
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Minus, Plus, FileText, ListPlus, HelpCircle, ArrowRight } from 'lucide-react';
|
import {
|
||||||
import { Input } from '../components/Input';
|
Minus,
|
||||||
|
Plus,
|
||||||
|
FileText,
|
||||||
|
ListPlus,
|
||||||
|
HelpCircle,
|
||||||
|
ArrowRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Input } from "../components/Input";
|
||||||
|
|
||||||
interface BaseStepProps {
|
interface BaseStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
updateState: (updates: Partial<FormState>) => void;
|
updateState: (_updates: Partial<FormState>) => void;
|
||||||
toggleItem: (list: string[], id: string) => string[];
|
toggleItem: (_list: string[], _id: string) => string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
||||||
const toggleDontKnow = (id: string) => {
|
const toggleDontKnow = (id: string) => {
|
||||||
const current = state.dontKnows || [];
|
const current = state.dontKnows || [];
|
||||||
if (current.includes(id)) {
|
if (current.includes(id)) {
|
||||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||||
} else {
|
} else {
|
||||||
updateState({ dontKnows: [...current, id] });
|
updateState({ dontKnows: [...current, id] });
|
||||||
}
|
}
|
||||||
@@ -52,12 +59,18 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h4 className="text-3xl font-bold text-slate-900 tracking-tight">Die Seitenstruktur</h4>
|
<h4 className="text-3xl font-bold text-slate-900 tracking-tight">
|
||||||
<span className="px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">Essenziell</span>
|
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>
|
||||||
<div className="flex items-center gap-2 text-slate-400 mt-1">
|
<div className="flex items-center gap-2 text-slate-400 mt-1">
|
||||||
<HelpCircle size={14} className="shrink-0" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,9 +78,11 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
|||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
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 ${
|
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
|
Ich weiß es nicht
|
||||||
@@ -75,12 +90,36 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<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: "Home",
|
||||||
{ id: 'Services', label: 'Leistungen', desc: 'Übersicht Ihres Angebots.' },
|
label: "Startseite",
|
||||||
{ id: 'Contact', label: 'Kontakt', desc: 'Anlaufstelle für Ihre Kunden.' },
|
desc: "Der erste Eindruck Ihrer Marke.",
|
||||||
{ id: 'Landing', label: 'Landingpage', desc: 'Optimiert für Marketing-Kampagnen.' },
|
},
|
||||||
{ id: 'Legal', label: 'Rechtliches', desc: 'Impressum & Datenschutz.' },
|
{
|
||||||
|
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) => (
|
].map((opt, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
@@ -89,9 +128,14 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
|||||||
transition={{ delay: index * 0.05 }}
|
transition={{ delay: index * 0.05 }}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={opt.label} desc={opt.desc}
|
label={opt.label}
|
||||||
|
desc={opt.desc}
|
||||||
checked={state.selectedPages.includes(opt.id)}
|
checked={state.selectedPages.includes(opt.id)}
|
||||||
onChange={() => updateState({ selectedPages: toggleItem(state.selectedPages, opt.id) })}
|
onChange={() =>
|
||||||
|
updateState({
|
||||||
|
selectedPages: toggleItem(state.selectedPages, opt.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
@@ -104,12 +148,18 @@ 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">
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||||
<ListPlus size={24} />
|
<ListPlus size={24} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<RepeatableList
|
<RepeatableList
|
||||||
items={state.otherPages}
|
items={state.otherPages}
|
||||||
onAdd={(v) => updateState({ otherPages: [...state.otherPages, v] })}
|
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..."
|
placeholder="z.B. Karriere, FAQ, Team-Detail..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,15 +176,24 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
|||||||
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center gap-8 relative z-10">
|
<div className="flex flex-col md:flex-row justify-between items-center gap-8 relative z-10">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-2xl font-bold text-white">Noch mehr Seiten?</h4>
|
<h4 className="text-2xl font-bold text-white">
|
||||||
<p className="text-lg text-slate-400 mt-1">Falls Sie die Namen noch nicht wissen, aber die Menge schätzen können.</p>
|
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>
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ otherPagesCount: Math.max(0, state.otherPagesCount - 1) })}
|
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"
|
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} />
|
<Minus size={28} />
|
||||||
@@ -154,7 +213,9 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
|||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
type="button"
|
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"
|
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} />
|
<Plus size={28} />
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState } from '../types';
|
import { FormState } from "../types";
|
||||||
import { EMPLOYEE_OPTIONS } from '../constants';
|
import { EMPLOYEE_OPTIONS } from "../constants";
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from "framer-motion";
|
||||||
import { Building2, Users } from 'lucide-react';
|
import { Building2, Users } from "lucide-react";
|
||||||
import { Reveal } from '../../Reveal';
|
import { Reveal } from "../../Reveal";
|
||||||
import { Input } from '../components/Input';
|
import { Input } from "../components/Input";
|
||||||
|
|
||||||
interface CompanyStepProps {
|
interface CompanyStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
updateState: (updates: Partial<FormState>) => void;
|
updateState: (_updates: Partial<FormState>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CompanyStep({ state, updateState }: CompanyStepProps) {
|
export function CompanyStep({ state, updateState }: CompanyStepProps) {
|
||||||
@@ -23,7 +23,9 @@ export function CompanyStep({ state, updateState }: CompanyStepProps) {
|
|||||||
<Building2 size={24} />
|
<Building2 size={24} />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-2xl font-bold text-slate-900">Unternehmen</h4>
|
<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>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
label="Name des Unternehmens"
|
label="Name des Unternehmens"
|
||||||
@@ -40,17 +42,22 @@ export function CompanyStep({ state, updateState }: CompanyStepProps) {
|
|||||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||||
<Users size={24} />
|
<Users size={24} />
|
||||||
</div>
|
</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>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||||
{EMPLOYEE_OPTIONS.map((option, index) => (
|
{EMPLOYEE_OPTIONS.map((option) => (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={option.id}
|
key={option.id}
|
||||||
whileHover={{ y: -5 }}
|
whileHover={{ y: -5 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ employeeCount: option.id })}
|
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}
|
{option.label}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState } from '../types';
|
import { FormState } from "../types";
|
||||||
import { FileText, Upload, X, User, Mail, Briefcase, MessageSquare } from 'lucide-react';
|
import {
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
FileText,
|
||||||
import { Reveal } from '../../Reveal';
|
Upload,
|
||||||
import { Input } from '../components/Input';
|
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 {
|
interface ContactStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
updateState: (updates: Partial<FormState>) => void;
|
updateState: (_updates: Partial<FormState>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactStep({ state, updateState }: ContactStepProps) {
|
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">
|
<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>
|
<h4 className="text-2xl font-bold mb-2">Fast geschafft! 🚀</h4>
|
||||||
<p className="text-slate-500 text-lg">
|
<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.
|
Ich habe alle Details für das Projekt von{" "}
|
||||||
Hinterlassen Sie mir noch Ihre Kontaktdaten, damit ich Ihnen ein konkretes Angebot erstellen kann.
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -84,24 +96,43 @@ export function ContactStep({ state, updateState }: ContactStepProps) {
|
|||||||
|
|
||||||
<Reveal width="100%" delay={0.4}>
|
<Reveal width="100%" delay={0.4}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<p className="text-lg font-bold text-slate-900 ml-2">Dateien hochladen (optional)</p>
|
<p className="text-lg font-bold text-slate-900 ml-2">
|
||||||
|
Dateien hochladen (optional)
|
||||||
|
</p>
|
||||||
<div
|
<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] ${
|
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) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const files = Array.from(e.dataTransfer.files);
|
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) => {
|
<input
|
||||||
|
id="contact-upload"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
const files = e.target.files ? Array.from(e.target.files) : [];
|
const files = e.target.files ? Array.from(e.target.files) : [];
|
||||||
if (files.length > 0) updateState({ contactFiles: [...state.contactFiles, ...files] });
|
if (files.length > 0)
|
||||||
}} />
|
updateState({
|
||||||
|
contactFiles: [...state.contactFiles, ...files],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{state.contactFiles.length > 0 ? (
|
{state.contactFiles.length > 0 ? (
|
||||||
@@ -125,17 +156,29 @@ export function ContactStep({ state, updateState }: ContactStepProps) {
|
|||||||
<FileText size={20} />
|
<FileText size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-bold text-base truncate max-w-[250px]">{file.name}</span>
|
<span className="font-bold text-base truncate max-w-[250px]">
|
||||||
<span className="text-[10px] text-slate-400 uppercase font-bold">{(file.size / 1024 / 1024).toFixed(2)} MB</span>
|
{file.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 uppercase font-bold">
|
||||||
|
{(file.size / 1024 / 1024).toFixed(2)} MB
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.1, backgroundColor: '#fee2e2', color: '#ef4444' }}
|
whileHover={{
|
||||||
|
scale: 1.1,
|
||||||
|
backgroundColor: "#fee2e2",
|
||||||
|
color: "#ef4444",
|
||||||
|
}}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
updateState({ contactFiles: state.contactFiles.filter((_, i) => i !== idx) });
|
updateState({
|
||||||
|
contactFiles: state.contactFiles.filter(
|
||||||
|
(_, i) => i !== idx,
|
||||||
|
),
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
className="p-2 bg-slate-50 text-slate-400 rounded-full transition-colors focus:outline-none"
|
className="p-2 bg-slate-50 text-slate-400 rounded-full transition-colors focus:outline-none"
|
||||||
>
|
>
|
||||||
@@ -143,7 +186,9 @@ export function ContactStep({ state, updateState }: ContactStepProps) {
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
</motion.div>
|
</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
|
<motion.div
|
||||||
@@ -156,8 +201,12 @@ export function ContactStep({ state, updateState }: ContactStepProps) {
|
|||||||
<Upload size={32} />
|
<Upload size={32} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-xl font-bold text-slate-900">Dateien hierher ziehen</p>
|
<p className="text-xl font-bold text-slate-900">
|
||||||
<p className="text-lg text-slate-500 mt-1">oder klicken zum Auswählen</p>
|
Dateien hierher ziehen
|
||||||
|
</p>
|
||||||
|
<p className="text-lg text-slate-500 mt-1">
|
||||||
|
oder klicken zum Auswählen
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState } from '../types';
|
import { FormState } from "../types";
|
||||||
import { Zap, AlertCircle, Minus, Plus, Settings2, BarChart3 } from 'lucide-react';
|
import {
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
Zap,
|
||||||
import { Reveal } from '../../Reveal';
|
AlertCircle,
|
||||||
|
Minus,
|
||||||
|
Plus,
|
||||||
|
Settings2,
|
||||||
|
BarChart3,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Reveal } from "../../Reveal";
|
||||||
|
|
||||||
interface ContentStepProps {
|
interface ContentStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
updateState: (updates: Partial<FormState>) => void;
|
updateState: (_updates: Partial<FormState>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContentStep({ state, updateState }: ContentStepProps) {
|
export function ContentStep({ state, updateState }: ContentStepProps) {
|
||||||
const toggleDontKnow = (id: string) => {
|
const toggleDontKnow = (id: string) => {
|
||||||
const current = state.dontKnows || [];
|
const current = state.dontKnows || [];
|
||||||
if (current.includes(id)) {
|
if (current.includes(id)) {
|
||||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||||
} else {
|
} else {
|
||||||
updateState({ dontKnows: [...current, id] });
|
updateState({ dontKnows: [...current, id] });
|
||||||
}
|
}
|
||||||
@@ -30,11 +37,14 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
|||||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||||
<Settings2 size={24} />
|
<Settings2 size={24} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<p className="text-lg text-slate-500 leading-relaxed">
|
<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.
|
Ein CMS (Content Management System) ermöglicht es Ihnen, Texte,
|
||||||
Ideal, wenn Sie Ihre Website aktuell halten möchten.
|
Bilder und Blogartikel selbst zu ändern, ohne programmieren zu
|
||||||
|
müssen. Ideal, wenn Sie Ihre Website aktuell halten möchten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center md:items-end gap-6">
|
<div className="flex flex-col items-center md:items-end gap-6">
|
||||||
@@ -42,9 +52,11 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
|||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleDontKnow('cms')}
|
onClick={() => toggleDontKnow("cms")}
|
||||||
className={`px-6 py-3 rounded-full text-sm font-bold transition-all ${
|
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
|
Ich weiß es nicht
|
||||||
@@ -52,7 +64,7 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ cmsSetup: !state.cmsSetup })}
|
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 }}
|
animate={{ x: state.cmsSetup ? 48 : 0 }}
|
||||||
@@ -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">
|
<div className="w-12 h-12 bg-white rounded-2xl flex items-center justify-center text-black">
|
||||||
<BarChart3 size={24} />
|
<BarChart3 size={24} />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
{[
|
{[
|
||||||
{ id: 'low', label: 'Selten', desc: 'Wenige Male im Jahr.' },
|
{ 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: "medium",
|
||||||
|
label: "Regelmäßig",
|
||||||
|
desc: "Monatliche Updates.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "high",
|
||||||
|
label: "Häufig",
|
||||||
|
desc: "Wöchentlich oder täglich.",
|
||||||
|
},
|
||||||
].map((opt, index) => (
|
].map((opt, index) => (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
@@ -86,20 +108,30 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ expectedAdjustments: opt.id })}
|
onClick={() => updateState({ expectedAdjustments: opt.id })}
|
||||||
className={`p-6 rounded-[2rem] border-2 text-left transition-all duration-300 focus:outline-none ${
|
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
|
||||||
<p className={`text-sm mt-2 leading-relaxed ${state.expectedAdjustments === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</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>
|
</motion.button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{state.expectedAdjustments === 'high' && !state.cmsSetup && (
|
{state.expectedAdjustments === "high" && !state.cmsSetup && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, height: 0, y: 20 }}
|
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 }}
|
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"
|
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} />
|
<AlertCircle size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -122,7 +158,8 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
|||||||
<Zap size={18} /> Vorteil CMS
|
<Zap size={18} /> Vorteil CMS
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base text-slate-500 leading-relaxed">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-8 bg-white rounded-[2.5rem] border border-slate-100 space-y-4">
|
<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
|
<AlertCircle size={18} /> Fokus Design
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base text-slate-500 leading-relaxed">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,10 +178,14 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
|||||||
<Reveal width="100%" delay={0.3}>
|
<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="flex flex-col gap-8 p-10 bg-white border border-slate-100 rounded-[3rem]">
|
||||||
<div className="space-y-2">
|
<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">
|
<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.
|
Wer kümmert sich um die erste Befüllung? Wenn ich das übernehmen
|
||||||
Ansonsten übergeben wir Ihnen eine leere, aber einsatzbereite Struktur.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-12 py-2">
|
<div className="flex items-center gap-12 py-2">
|
||||||
@@ -151,7 +193,9 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
|||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ newDatasets: Math.max(0, state.newDatasets - 1) })}
|
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"
|
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} />
|
<Minus size={28} />
|
||||||
@@ -171,7 +215,9 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
|
|||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ newDatasets: state.newDatasets + 1 })}
|
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"
|
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} />
|
<Plus size={28} />
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState } from '../types';
|
import { FormState } from "../types";
|
||||||
import { DESIGN_VIBES } from '../constants';
|
import { DESIGN_VIBES } from "../constants";
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Plus, X, Palette, Pipette, RefreshCw } from 'lucide-react';
|
import { Plus, X, Palette, Pipette, RefreshCw } from "lucide-react";
|
||||||
import { Reveal } from '../../Reveal';
|
import { Reveal } from "../../Reveal";
|
||||||
import { Input } from '../components/Input';
|
import { Input } from "../components/Input";
|
||||||
import { RepeatableList } from '../components/RepeatableList';
|
import { RepeatableList } from "../components/RepeatableList";
|
||||||
|
|
||||||
interface DesignStepProps {
|
interface DesignStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
updateState: (updates: Partial<FormState>) => void;
|
updateState: (_updates: Partial<FormState>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DesignStep({ state, updateState }: DesignStepProps) {
|
export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||||
const addColor = () => {
|
const addColor = () => {
|
||||||
if (state.colorScheme.length < 5) {
|
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 toggleDontKnow = (id: string) => {
|
||||||
const current = state.dontKnows || [];
|
const current = state.dontKnows || [];
|
||||||
if (current.includes(id)) {
|
if (current.includes(id)) {
|
||||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||||
} else {
|
} else {
|
||||||
updateState({ dontKnows: [...current, id] });
|
updateState({ dontKnows: [...current, id] });
|
||||||
}
|
}
|
||||||
@@ -51,11 +51,13 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
|||||||
|
|
||||||
const hslToHex = (h: number, s: number, l: number) => {
|
const hslToHex = (h: number, s: number, l: number) => {
|
||||||
l /= 100;
|
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 f = (n: number) => {
|
||||||
const k = (n + h / 30) % 12;
|
const k = (n + h / 30) % 12;
|
||||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
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)}`;
|
return `#${f(0)}${f(8)}${f(4)}`;
|
||||||
};
|
};
|
||||||
@@ -63,7 +65,7 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
|||||||
const count = state.colorScheme.length;
|
const count = state.colorScheme.length;
|
||||||
const palette = [];
|
const palette = [];
|
||||||
for (let i = 0; i < count; i++) {
|
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;
|
const l = i === 0 ? 95 : i === count - 1 ? 20 : lightness;
|
||||||
palette.push(hslToHex(h, saturation, l));
|
palette.push(hslToHex(h, saturation, l));
|
||||||
}
|
}
|
||||||
@@ -77,16 +79,22 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h4 className="text-2xl font-bold text-slate-900">Design-Richtung</h4>
|
<h4 className="text-2xl font-bold text-slate-900">
|
||||||
<p className="text-slate-500">Welche Ästhetik passt zu Ihrer Marke?</p>
|
Design-Richtung
|
||||||
|
</h4>
|
||||||
|
<p className="text-slate-500">
|
||||||
|
Welche Ästhetik passt zu Ihrer Marke?
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
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 ${
|
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
|
Ich weiß es nicht
|
||||||
@@ -100,14 +108,31 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ designVibe: vibe.id })}
|
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 ${
|
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>
|
<div
|
||||||
<h4 className={`text-2xl font-bold mb-3 ${state.designVibe === vibe.id ? 'text-white' : 'text-slate-900'}`}>{vibe.label}</h4>
|
className={`w-16 h-10 mb-6 transition-transform duration-500 group-hover:scale-110 ${state.designVibe === vibe.id ? "text-white" : "text-black"}`}
|
||||||
<p className={`text-lg leading-relaxed ${state.designVibe === vibe.id ? 'text-slate-200' : 'text-slate-500'}`}>{vibe.desc}</p>
|
>
|
||||||
|
{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 && (
|
{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>
|
</motion.button>
|
||||||
))}
|
))}
|
||||||
@@ -121,7 +146,10 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h4 className="text-2xl font-bold text-slate-900">Farbschema</h4>
|
<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>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<motion.button
|
<motion.button
|
||||||
@@ -138,9 +166,11 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
|||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
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 ${
|
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
|
Ich weiß es nicht
|
||||||
@@ -174,7 +204,9 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
|||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 pointer-events-none border border-black/5 rounded-3xl" />
|
<div className="absolute inset-0 pointer-events-none border border-black/5 rounded-3xl" />
|
||||||
</div>
|
</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 && (
|
{state.colorScheme.length > 1 && (
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
@@ -192,18 +224,27 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
|||||||
{state.colorScheme.length < 5 && (
|
{state.colorScheme.length < 5 && (
|
||||||
<motion.button
|
<motion.button
|
||||||
layout
|
layout
|
||||||
whileHover={{ scale: 1.05, borderColor: '#0f172a', color: '#0f172a' }}
|
whileHover={{
|
||||||
|
scale: 1.05,
|
||||||
|
borderColor: "#0f172a",
|
||||||
|
color: "#0f172a",
|
||||||
|
}}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addColor}
|
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"
|
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} />
|
<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>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -212,14 +253,26 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
|
|||||||
<Reveal width="100%" delay={0.3}>
|
<Reveal width="100%" delay={0.3}>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h4 className="text-2xl font-bold text-slate-900">Referenz-Websites</h4>
|
<h4 className="text-2xl font-bold text-slate-900">
|
||||||
<p className="text-slate-500">Gibt es Websites, die Ihnen besonders gut gefallen?</p>
|
Referenz-Websites
|
||||||
|
</h4>
|
||||||
|
<p className="text-slate-500">
|
||||||
|
Gibt es Websites, die Ihnen besonders gut gefallen?
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-10 bg-white border border-slate-100 rounded-[3rem]">
|
<div className="p-10 bg-white border border-slate-100 rounded-[3rem]">
|
||||||
<RepeatableList
|
<RepeatableList
|
||||||
items={state.references || []}
|
items={state.references || []}
|
||||||
onAdd={(v) => updateState({ references: [...(state.references || []), v] })}
|
onAdd={(v) =>
|
||||||
onRemove={(i) => updateState({ references: (state.references || []).filter((_, idx) => idx !== i) })}
|
updateState({ references: [...(state.references || []), v] })
|
||||||
|
}
|
||||||
|
onRemove={(i) =>
|
||||||
|
updateState({
|
||||||
|
references: (state.references || []).filter(
|
||||||
|
(_, idx) => idx !== i,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="https://beispiel.de"
|
placeholder="https://beispiel.de"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState } from '../types';
|
import { FormState } from "../types";
|
||||||
import { FEATURE_OPTIONS } from '../constants';
|
import { FEATURE_OPTIONS } from "../constants";
|
||||||
import { Checkbox } from '../components/Checkbox';
|
import { Checkbox } from "../components/Checkbox";
|
||||||
import { RepeatableList } from '../components/RepeatableList';
|
import { RepeatableList } from "../components/RepeatableList";
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Minus, Plus, LayoutGrid, ListPlus, HelpCircle } from 'lucide-react';
|
import { Minus, Plus, LayoutGrid, ListPlus, HelpCircle } from "lucide-react";
|
||||||
|
|
||||||
interface FeaturesStepProps {
|
interface FeaturesStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
updateState: (updates: Partial<FormState>) => void;
|
updateState: (_updates: Partial<FormState>) => void;
|
||||||
toggleItem: (list: string[], id: string) => string[];
|
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 toggleDontKnow = (id: string) => {
|
||||||
const current = state.dontKnows || [];
|
const current = state.dontKnows || [];
|
||||||
if (current.includes(id)) {
|
if (current.includes(id)) {
|
||||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||||
} else {
|
} else {
|
||||||
updateState({ dontKnows: [...current, id] });
|
updateState({ dontKnows: [...current, id] });
|
||||||
}
|
}
|
||||||
@@ -34,12 +38,19 @@ export function FeaturesStep({ state, updateState, toggleItem }: FeaturesStepPro
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h4 className="text-2xl font-bold text-slate-900">System-Module</h4>
|
<h4 className="text-2xl font-bold text-slate-900">
|
||||||
<span className="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-bold uppercase tracking-wider rounded">Optional</span>
|
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>
|
||||||
<div className="flex items-center gap-2 text-slate-400 mt-1">
|
<div className="flex items-center gap-2 text-slate-400 mt-1">
|
||||||
<HelpCircle size={14} />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,9 +58,11 @@ export function FeaturesStep({ state, updateState, toggleItem }: FeaturesStepPro
|
|||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
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 ${
|
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
|
Ich weiß es nicht
|
||||||
@@ -64,9 +77,12 @@ export function FeaturesStep({ state, updateState, toggleItem }: FeaturesStepPro
|
|||||||
transition={{ delay: index * 0.05 }}
|
transition={{ delay: index * 0.05 }}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={opt.label} desc={opt.desc}
|
label={opt.label}
|
||||||
|
desc={opt.desc}
|
||||||
checked={state.features.includes(opt.id)}
|
checked={state.features.includes(opt.id)}
|
||||||
onChange={() => updateState({ features: toggleItem(state.features, opt.id) })}
|
onChange={() =>
|
||||||
|
updateState({ features: toggleItem(state.features, opt.id) })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</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">
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||||
<ListPlus size={24} />
|
<ListPlus size={24} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<RepeatableList
|
<RepeatableList
|
||||||
items={state.otherFeatures}
|
items={state.otherFeatures}
|
||||||
onAdd={(v) => updateState({ otherFeatures: [...state.otherFeatures, v] })}
|
onAdd={(v) =>
|
||||||
onRemove={(i) => updateState({ otherFeatures: state.otherFeatures.filter((_, idx) => idx !== i) })}
|
updateState({ otherFeatures: [...state.otherFeatures, v] })
|
||||||
|
}
|
||||||
|
onRemove={(i) =>
|
||||||
|
updateState({
|
||||||
|
otherFeatures: state.otherFeatures.filter(
|
||||||
|
(_, idx) => idx !== i,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="z.B. Glossar, Download-Center, Partner-Bereich..."
|
placeholder="z.B. Glossar, Download-Center, Partner-Bereich..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState } from '../types';
|
import { FormState } from "../types";
|
||||||
import { Checkbox } from '../components/Checkbox';
|
import { Checkbox } from "../components/Checkbox";
|
||||||
import { RepeatableList } from '../components/RepeatableList';
|
import { RepeatableList } from "../components/RepeatableList";
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Minus, Plus, Cpu, ListPlus } from 'lucide-react';
|
import { Minus, Plus, Cpu, ListPlus } from "lucide-react";
|
||||||
import { Reveal } from '../../Reveal';
|
import { Reveal } from "../../Reveal";
|
||||||
|
|
||||||
interface FunctionsStepProps {
|
interface FunctionsStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
updateState: (updates: Partial<FormState>) => void;
|
updateState: (_updates: Partial<FormState>) => void;
|
||||||
toggleItem: (list: string[], id: string) => string[];
|
toggleItem: (_list: string[], _id: string) => string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepProps) {
|
export function FunctionsStep({
|
||||||
const isWebApp = state.projectType === 'web-app';
|
state,
|
||||||
|
updateState,
|
||||||
|
toggleItem,
|
||||||
|
}: FunctionsStepProps) {
|
||||||
|
const isWebApp = state.projectType === "web-app";
|
||||||
|
|
||||||
const toggleDontKnow = (id: string) => {
|
const toggleDontKnow = (id: string) => {
|
||||||
const current = state.dontKnows || [];
|
const current = state.dontKnows || [];
|
||||||
if (current.includes(id)) {
|
if (current.includes(id)) {
|
||||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||||
} else {
|
} else {
|
||||||
updateState({ dontKnows: [...current, id] });
|
updateState({ dontKnows: [...current, id] });
|
||||||
}
|
}
|
||||||
@@ -36,16 +40,20 @@ export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepP
|
|||||||
<Cpu size={24} />
|
<Cpu size={24} />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-2xl font-bold text-slate-900">
|
<h4 className="text-2xl font-bold text-slate-900">
|
||||||
{isWebApp ? 'Funktionale Anforderungen' : 'Erweiterte Funktionen'}
|
{isWebApp
|
||||||
|
? "Funktionale Anforderungen"
|
||||||
|
: "Erweiterte Funktionen"}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
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 ${
|
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
|
Ich weiß es nicht
|
||||||
@@ -55,62 +63,117 @@ export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepP
|
|||||||
{isWebApp ? (
|
{isWebApp ? (
|
||||||
<>
|
<>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Dashboard & Analytics" desc="Visualisierung von Daten und Kennzahlen."
|
label="Dashboard & Analytics"
|
||||||
checked={state.functions.includes('dashboard')}
|
desc="Visualisierung von Daten und Kennzahlen."
|
||||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'dashboard') })}
|
checked={state.functions.includes("dashboard")}
|
||||||
|
onChange={() =>
|
||||||
|
updateState({
|
||||||
|
functions: toggleItem(state.functions, "dashboard"),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Dateiverwaltung" desc="Upload, Download und Organisation von Dokumenten."
|
label="Dateiverwaltung"
|
||||||
checked={state.functions.includes('files')}
|
desc="Upload, Download und Organisation von Dokumenten."
|
||||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'files') })}
|
checked={state.functions.includes("files")}
|
||||||
|
onChange={() =>
|
||||||
|
updateState({
|
||||||
|
functions: toggleItem(state.functions, "files"),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Benachrichtigungen" desc="E-Mail, Push oder In-App Alerts."
|
label="Benachrichtigungen"
|
||||||
checked={state.functions.includes('notifications')}
|
desc="E-Mail, Push oder In-App Alerts."
|
||||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'notifications') })}
|
checked={state.functions.includes("notifications")}
|
||||||
|
onChange={() =>
|
||||||
|
updateState({
|
||||||
|
functions: toggleItem(state.functions, "notifications"),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Export-Funktionen" desc="CSV, Excel oder PDF Generierung."
|
label="Export-Funktionen"
|
||||||
checked={state.functions.includes('export')}
|
desc="CSV, Excel oder PDF Generierung."
|
||||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'export') })}
|
checked={state.functions.includes("export")}
|
||||||
|
onChange={() =>
|
||||||
|
updateState({
|
||||||
|
functions: toggleItem(state.functions, "export"),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Suche" desc="Volltextsuche über alle Inhalte."
|
label="Suche"
|
||||||
checked={state.functions.includes('search')}
|
desc="Volltextsuche über alle Inhalte."
|
||||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'search') })}
|
checked={state.functions.includes("search")}
|
||||||
|
onChange={() =>
|
||||||
|
updateState({
|
||||||
|
functions: toggleItem(state.functions, "search"),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Filter-Systeme" desc="Kategorisierung und Sortierung."
|
label="Filter-Systeme"
|
||||||
checked={state.functions.includes('filter')}
|
desc="Kategorisierung und Sortierung."
|
||||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'filter') })}
|
checked={state.functions.includes("filter")}
|
||||||
|
onChange={() =>
|
||||||
|
updateState({
|
||||||
|
functions: toggleItem(state.functions, "filter"),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="PDF-Export" desc="Automatisierte PDF-Erstellung."
|
label="PDF-Export"
|
||||||
checked={state.functions.includes('pdf')}
|
desc="Automatisierte PDF-Erstellung."
|
||||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'pdf') })}
|
checked={state.functions.includes("pdf")}
|
||||||
|
onChange={() =>
|
||||||
|
updateState({
|
||||||
|
functions: toggleItem(state.functions, "pdf"),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Erweiterte Formulare" desc="Komplexe Abfragen & Logik."
|
label="Erweiterte Formulare"
|
||||||
checked={state.functions.includes('forms')}
|
desc="Komplexe Abfragen & Logik."
|
||||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'forms') })}
|
checked={state.functions.includes("forms")}
|
||||||
|
onChange={() =>
|
||||||
|
updateState({
|
||||||
|
functions: toggleItem(state.functions, "forms"),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Mitgliederbereich" desc="Login-Bereich für exklusive Inhalte."
|
label="Mitgliederbereich"
|
||||||
checked={state.functions.includes('members')}
|
desc="Login-Bereich für exklusive Inhalte."
|
||||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'members') })}
|
checked={state.functions.includes("members")}
|
||||||
|
onChange={() =>
|
||||||
|
updateState({
|
||||||
|
functions: toggleItem(state.functions, "members"),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Event-Kalender" desc="Verwaltung und Anzeige von Terminen."
|
label="Event-Kalender"
|
||||||
checked={state.functions.includes('calendar')}
|
desc="Verwaltung und Anzeige von Terminen."
|
||||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'calendar') })}
|
checked={state.functions.includes("calendar")}
|
||||||
|
onChange={() =>
|
||||||
|
updateState({
|
||||||
|
functions: toggleItem(state.functions, "calendar"),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Echtzeit-Chat" desc="Direkte Kommunikation mit Besuchern."
|
label="Echtzeit-Chat"
|
||||||
checked={state.functions.includes('chat')}
|
desc="Direkte Kommunikation mit Besuchern."
|
||||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'chat') })}
|
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">
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||||
<ListPlus size={24} />
|
<ListPlus size={24} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<RepeatableList
|
<RepeatableList
|
||||||
items={state.otherFunctions}
|
items={state.otherFunctions}
|
||||||
onAdd={(v) => updateState({ otherFunctions: [...state.otherFunctions, v] })}
|
onAdd={(v) =>
|
||||||
onRemove={(i) => updateState({ otherFunctions: state.otherFunctions.filter((_, idx) => idx !== i) })}
|
updateState({ otherFunctions: [...state.otherFunctions, v] })
|
||||||
placeholder={isWebApp ? "z.B. Echtzeit-Chat, KI-Anbindung, Offline-Modus..." : "z.B. Mitgliederbereich, Event-Kalender, geschützte Downloads..."}
|
}
|
||||||
|
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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,31 +1,32 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState } from '../types';
|
import { FormState } from "../types";
|
||||||
import { Globe, Info, Plus, X } from 'lucide-react';
|
import { Globe, Info, Plus, X } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Reveal } from '../../Reveal';
|
import { Reveal } from "../../Reveal";
|
||||||
|
|
||||||
interface LanguageStepProps {
|
interface LanguageStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
updateState: (updates: Partial<FormState>) => void;
|
updateState: (_updates: Partial<FormState>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMON_LANGUAGES = [
|
const COMMON_LANGUAGES = [
|
||||||
{ id: 'de', label: 'Deutsch' },
|
{ id: "de", label: "Deutsch" },
|
||||||
{ id: 'en', label: 'Englisch' },
|
{ id: "en", label: "Englisch" },
|
||||||
{ id: 'fr', label: 'Französisch' },
|
{ id: "fr", label: "Französisch" },
|
||||||
{ id: 'es', label: 'Spanisch' },
|
{ id: "es", label: "Spanisch" },
|
||||||
{ id: 'it', label: 'Italienisch' },
|
{ id: "it", label: "Italienisch" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
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 toggleLanguage = (lang: string) => {
|
||||||
const current = state.languagesList || [];
|
const current = state.languagesList || [];
|
||||||
if (current.includes(lang)) {
|
if (current.includes(lang)) {
|
||||||
updateState({ languagesList: current.filter(l => l !== lang) });
|
updateState({ languagesList: current.filter((l) => l !== lang) });
|
||||||
} else {
|
} else {
|
||||||
updateState({ languagesList: [...current, lang] });
|
updateState({ languagesList: [...current, lang] });
|
||||||
}
|
}
|
||||||
@@ -34,7 +35,7 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
|||||||
const toggleDontKnow = (id: string) => {
|
const toggleDontKnow = (id: string) => {
|
||||||
const current = state.dontKnows || [];
|
const current = state.dontKnows || [];
|
||||||
if (current.includes(id)) {
|
if (current.includes(id)) {
|
||||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||||
} else {
|
} else {
|
||||||
updateState({ dontKnows: [...current, id] });
|
updateState({ dontKnows: [...current, id] });
|
||||||
}
|
}
|
||||||
@@ -52,15 +53,19 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
|||||||
<Globe size={24} />
|
<Globe size={24} />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-2xl font-bold text-slate-900">Sprachen</h4>
|
<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>
|
</div>
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
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 ${
|
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
|
Ich weiß es nicht
|
||||||
@@ -82,8 +87,8 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
|||||||
onClick={() => toggleLanguage(lang.label)}
|
onClick={() => toggleLanguage(lang.label)}
|
||||||
className={`px-8 py-4 rounded-2xl font-bold transition-all border-2 ${
|
className={`px-8 py-4 rounded-2xl font-bold transition-all border-2 ${
|
||||||
state.languagesList.includes(lang.label)
|
state.languagesList.includes(lang.label)
|
||||||
? 'bg-slate-900 border-slate-900 text-white'
|
? "bg-slate-900 border-slate-900 text-white"
|
||||||
: 'bg-white border-slate-100 text-slate-600 hover:border-slate-300'
|
: "bg-white border-slate-100 text-slate-600 hover:border-slate-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{lang.label}
|
{lang.label}
|
||||||
@@ -98,12 +103,14 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
|||||||
placeholder="Weitere Sprache hinzufügen..."
|
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"
|
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) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const val = e.currentTarget.value.trim();
|
const val = e.currentTarget.value.trim();
|
||||||
if (val && !state.languagesList.includes(val)) {
|
if (val && !state.languagesList.includes(val)) {
|
||||||
updateState({ languagesList: [...state.languagesList, val] });
|
updateState({
|
||||||
e.currentTarget.value = '';
|
languagesList: [...state.languagesList, val],
|
||||||
|
});
|
||||||
|
e.currentTarget.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -113,7 +120,9 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
|||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{state.languagesList.filter(l => !COMMON_LANGUAGES.find(cl => cl.label === l)).map((lang, i) => (
|
{state.languagesList
|
||||||
|
.filter((l) => !COMMON_LANGUAGES.find((cl) => cl.label === l))
|
||||||
|
.map((lang, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={lang}
|
key={lang}
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
@@ -124,7 +133,13 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
|||||||
<span>{lang}</span>
|
<span>{lang}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ languagesList: state.languagesList.filter(l => l !== lang) })}
|
onClick={() =>
|
||||||
|
updateState({
|
||||||
|
languagesList: state.languagesList.filter(
|
||||||
|
(l) => l !== lang,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
className="text-slate-400 hover:text-slate-900 transition-colors"
|
className="text-slate-400 hover:text-slate-900 transition-colors"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
@@ -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="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">
|
<div className="flex items-center gap-4 text-slate-400 relative z-10">
|
||||||
<Info size={24} />
|
<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>
|
</div>
|
||||||
<p className="text-lg leading-relaxed text-slate-300 relative z-10 max-w-3xl">
|
<p className="text-lg leading-relaxed text-slate-300 relative z-10 max-w-3xl">
|
||||||
{basePriceExplanation}
|
{basePriceExplanation}
|
||||||
@@ -150,13 +167,15 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
|||||||
{languagesCount > 1 && (
|
{languagesCount > 1 && (
|
||||||
<div className="pt-8 border-t border-white/10 relative z-10">
|
<div className="pt-8 border-t border-white/10 relative z-10">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-lg font-medium text-slate-400">Aktueller Aufschlagsfaktor:</span>
|
<span className="text-lg font-medium text-slate-400">
|
||||||
|
Aktueller Aufschlagsfaktor:
|
||||||
|
</span>
|
||||||
<motion.span
|
<motion.span
|
||||||
initial={{ scale: 0.8 }}
|
initial={{ scale: 0.8 }}
|
||||||
animate={{ scale: 1 }}
|
animate={{ scale: 1 }}
|
||||||
className="text-4xl font-bold text-white"
|
className="text-4xl font-bold text-white"
|
||||||
>
|
>
|
||||||
+{((languagesCount - 1) * 20)}%
|
+{(languagesCount - 1) * 20}%
|
||||||
</motion.span>
|
</motion.span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,35 +1,48 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState } from '../types';
|
import { FormState } from "../types";
|
||||||
import { Checkbox } from '../components/Checkbox';
|
import { Checkbox } from "../components/Checkbox";
|
||||||
import { Link2, Globe, Share2, Instagram, Linkedin, Facebook, Twitter, Youtube } from 'lucide-react';
|
import {
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
Link2,
|
||||||
import { Reveal } from '../../Reveal';
|
Globe,
|
||||||
import { Input } from '../components/Input';
|
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 {
|
interface PresenceStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
updateState: (updates: Partial<FormState>) => void;
|
updateState: (_updates: Partial<FormState>) => void;
|
||||||
toggleItem: (list: string[], id: string) => string[];
|
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) => {
|
const updateUrl = (id: string, url: string) => {
|
||||||
updateState({
|
updateState({
|
||||||
socialMediaUrls: {
|
socialMediaUrls: {
|
||||||
...state.socialMediaUrls,
|
...state.socialMediaUrls,
|
||||||
[id]: url
|
[id]: url,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const SOCIAL_PLATFORMS = [
|
const SOCIAL_PLATFORMS = [
|
||||||
{ id: 'instagram', label: 'Instagram', icon: Instagram },
|
{ id: "instagram", label: "Instagram", icon: Instagram },
|
||||||
{ id: 'linkedin', label: 'LinkedIn', icon: Linkedin },
|
{ id: "linkedin", label: "LinkedIn", icon: Linkedin },
|
||||||
{ id: 'facebook', label: 'Facebook', icon: Facebook },
|
{ id: "facebook", label: "Facebook", icon: Facebook },
|
||||||
{ id: 'twitter', label: 'Twitter / X', icon: Twitter },
|
{ id: "twitter", label: "Twitter / X", icon: Twitter },
|
||||||
{ id: 'youtube', label: 'YouTube', icon: Youtube },
|
{ id: "youtube", label: "YouTube", icon: Youtube },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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">
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||||
<Globe size={24} />
|
<Globe size={24} />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-2xl font-bold text-slate-900">Bestehende Website</h4>
|
<h4 className="text-2xl font-bold text-slate-900">
|
||||||
<span className="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-bold uppercase tracking-wider rounded">Optional</span>
|
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>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
label="URL (falls vorhanden)"
|
label="URL (falls vorhanden)"
|
||||||
@@ -79,7 +96,9 @@ 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">
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
|
||||||
<Share2 size={24} />
|
<Share2 size={24} />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||||
@@ -92,15 +111,25 @@ export function PresenceStep({ state, updateState, toggleItem }: PresenceStepPro
|
|||||||
whileHover={{ y: -8, scale: 1.02 }}
|
whileHover={{ y: -8, scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
type="button"
|
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 ${
|
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} />
|
<Icon size={32} />
|
||||||
</div>
|
</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>
|
</motion.button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -109,7 +138,7 @@ export function PresenceStep({ state, updateState, toggleItem }: PresenceStepPro
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
{state.socialMedia.map((id) => {
|
{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;
|
if (!platform) return null;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -121,14 +150,16 @@ export function PresenceStep({ state, updateState, toggleItem }: PresenceStepPro
|
|||||||
className="relative group"
|
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">
|
<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" />
|
<div className="w-[1px] h-4 bg-slate-200" />
|
||||||
<Link2 size={18} />
|
<Link2 size={18} />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
placeholder={`https://${platform.id}.com/ihr-profil`}
|
placeholder={`https://${platform.id}.com/ihr-profil`}
|
||||||
value={state.socialMediaUrls[id] || ''}
|
value={state.socialMediaUrls[id] || ""}
|
||||||
onChange={(e) => updateUrl(id, e.target.value)}
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
@@ -139,7 +170,9 @@ export function PresenceStep({ state, updateState, toggleItem }: PresenceStepPro
|
|||||||
|
|
||||||
{state.socialMedia.length === 0 && (
|
{state.socialMedia.length === 0 && (
|
||||||
<div className="p-12 border-2 border-dashed border-slate-100 rounded-[3rem] text-center">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState } from '../types';
|
import { FormState } from "../types";
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
interface TimelineStepProps {
|
interface TimelineStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
updateState: (updates: Partial<FormState>) => void;
|
updateState: (_updates: Partial<FormState>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimelineStep({ state, updateState }: TimelineStepProps) {
|
export function TimelineStep({ state, updateState }: TimelineStepProps) {
|
||||||
const isMissingAssets = !state.assets.includes('logo') || !state.assets.includes('content_concept');
|
const isMissingAssets =
|
||||||
const isMissingPages = state.selectedPages.length === 0 && state.otherPages.length === 0 && state.otherPagesCount === 0;
|
!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 toggleDontKnow = (id: string) => {
|
||||||
const current = state.dontKnows || [];
|
const current = state.dontKnows || [];
|
||||||
if (current.includes(id)) {
|
if (current.includes(id)) {
|
||||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
updateState({ dontKnows: current.filter((i) => i !== id) });
|
||||||
} else {
|
} else {
|
||||||
updateState({ dontKnows: [...current, id] });
|
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>
|
<h4 className="text-2xl font-bold text-slate-900">Zeitplan</h4>
|
||||||
<button
|
<button
|
||||||
type="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 ${
|
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
|
Ich weiß es nicht
|
||||||
@@ -39,30 +45,58 @@ export function TimelineStep({ state, updateState }: TimelineStepProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<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: "asap",
|
||||||
{ id: '3-6-months', label: 'In 3-6 Monaten', desc: 'Langfristige Planung.' },
|
label: "So schnell wie möglich",
|
||||||
{ id: 'flexible', label: 'Flexibel', desc: 'Kein fester Termindruck.' },
|
desc: "Priorisierter Start gewünscht.",
|
||||||
].map(opt => (
|
},
|
||||||
|
{
|
||||||
|
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
|
<button
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ deadline: opt.id })}
|
onClick={() => updateState({ deadline: opt.id })}
|
||||||
className={`p-10 rounded-[2.5rem] border-2 text-left transition-all focus:outline-none overflow-hidden relative ${
|
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>
|
<h4
|
||||||
<p className={`text-lg ${state.deadline === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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} />
|
<AlertCircle className="text-slate-900 shrink-0 mt-1" size={28} />
|
||||||
<p className="text-base text-slate-600 leading-relaxed">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -73,9 +107,15 @@ export function TimelineStep({ state, updateState }: TimelineStepProps) {
|
|||||||
<AlertCircle size={24} />
|
<AlertCircle size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,35 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState, ProjectType } from '../types';
|
import { FormState, ProjectType } from "../types";
|
||||||
import { ConceptWebsite, ConceptSystem } from '../../Landing/ConceptIllustrations';
|
import {
|
||||||
import { motion } from 'framer-motion';
|
ConceptWebsite,
|
||||||
import { Reveal } from '../../Reveal';
|
ConceptSystem,
|
||||||
|
} from "../../Landing/ConceptIllustrations";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Reveal } from "../../Reveal";
|
||||||
|
|
||||||
interface TypeStepProps {
|
interface TypeStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
updateState: (updates: Partial<FormState>) => void;
|
updateState: (_updates: Partial<FormState>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TypeStep({ state, updateState }: TypeStepProps) {
|
export function TypeStep({ state, updateState }: TypeStepProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<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) => (
|
].map((type, index) => (
|
||||||
<Reveal key={type.id} width="100%" delay={index * 0.1}>
|
<Reveal key={type.id} width="100%" delay={index * 0.1}>
|
||||||
<motion.button
|
<motion.button
|
||||||
@@ -25,15 +38,34 @@ export function TypeStep({ state, updateState }: TypeStepProps) {
|
|||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ projectType: type.id as ProjectType })}
|
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
|
||||||
<div className="flex items-center gap-4 mb-6">
|
className={`transition-all duration-700 group-hover:scale-110 group-hover:rotate-3 ${state.projectType === type.id ? "text-white" : "text-slate-900"}`}
|
||||||
<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>
|
{type.illustration}
|
||||||
</div>
|
</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 && (
|
{state.projectType === type.id && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { FormState } from '../types';
|
import { FormState } from "../types";
|
||||||
import { Users, Shield, Monitor, Smartphone, Globe, Lock } from 'lucide-react';
|
import { Users, Shield, Monitor, Smartphone, Globe, Lock } from "lucide-react";
|
||||||
|
|
||||||
interface WebAppStepProps {
|
interface WebAppStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
updateState: (updates: Partial<FormState>) => void;
|
updateState: (_updates: Partial<FormState>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WebAppStep({ state, updateState }: WebAppStepProps) {
|
export function WebAppStep({ state, updateState }: WebAppStepProps) {
|
||||||
const toggleUserRole = (role: string) => {
|
const toggleUserRole = (role: string) => {
|
||||||
const current = state.userRoles || [];
|
const current = state.userRoles || [];
|
||||||
const next = current.includes(role)
|
const next = current.includes(role)
|
||||||
? current.filter(r => r !== role)
|
? current.filter((r) => r !== role)
|
||||||
: [...current, role];
|
: [...current, role];
|
||||||
updateState({ userRoles: next });
|
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">
|
<h4 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
|
||||||
<Users size={24} className="text-black" /> Zielgruppe
|
<Users size={24} className="text-black" /> Zielgruppe
|
||||||
</h4>
|
</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>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<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).' },
|
id: "internal",
|
||||||
].map(opt => (
|
label: "Internes Tool",
|
||||||
|
desc: "Für Mitarbeiter & Prozesse.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "external",
|
||||||
|
label: "Kunden-Portal",
|
||||||
|
desc: "Für Ihre Endnutzer (B2B/B2C).",
|
||||||
|
},
|
||||||
|
].map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ targetAudience: opt.id })}
|
onClick={() => updateState({ targetAudience: opt.id })}
|
||||||
className={`p-8 rounded-[2rem] border-2 text-left transition-all ${
|
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-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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -53,13 +69,21 @@ export function WebAppStep({ state, updateState }: WebAppStepProps) {
|
|||||||
<h4 className="text-2xl font-bold text-slate-900">Benutzerrollen</h4>
|
<h4 className="text-2xl font-bold text-slate-900">Benutzerrollen</h4>
|
||||||
<p className="text-lg text-slate-500">Wer wird das System nutzen?</p>
|
<p className="text-lg text-slate-500">Wer wird das System nutzen?</p>
|
||||||
<div className="flex flex-wrap gap-4">
|
<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
|
<button
|
||||||
key={role}
|
key={role}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleUserRole(role)}
|
onClick={() => toggleUserRole(role)}
|
||||||
className={`px-8 py-4 rounded-full border-2 font-bold text-base transition-all ${
|
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}
|
{role}
|
||||||
@@ -75,19 +99,37 @@ export function WebAppStep({ state, updateState }: WebAppStepProps) {
|
|||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<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: "desktop",
|
||||||
{ id: 'pwa', label: 'PWA (Installierbar)', icon: <Globe size={24} /> },
|
label: "Desktop First",
|
||||||
].map(opt => (
|
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
|
<button
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ platformType: opt.id })}
|
onClick={() => updateState({ platformType: opt.id })}
|
||||||
className={`p-8 rounded-[2rem] border-2 flex flex-col items-center gap-4 transition-all ${
|
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}
|
{opt.icon}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-lg">{opt.label}</span>
|
<span className="font-bold text-lg">{opt.label}</span>
|
||||||
@@ -103,19 +145,33 @@ export function WebAppStep({ state, updateState }: WebAppStepProps) {
|
|||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<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.' },
|
id: "standard",
|
||||||
].map(opt => (
|
label: "Standard",
|
||||||
|
desc: "Normale Nutzerdaten & Profile.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "high",
|
||||||
|
label: "Sensibel",
|
||||||
|
desc: "Finanzdaten, Gesundheitsdaten oder DSGVO-kritisch.",
|
||||||
|
},
|
||||||
|
].map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateState({ dataSensitivity: opt.id })}
|
onClick={() => updateState({ dataSensitivity: opt.id })}
|
||||||
className={`p-8 rounded-[2rem] border-2 text-left transition-all ${
|
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-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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<h4 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
|
||||||
<Lock size={24} className="text-black" /> Authentifizierung
|
<Lock size={24} className="text-black" /> Authentifizierung
|
||||||
</h4>
|
</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">
|
<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
|
<div
|
||||||
key={method}
|
key={method}
|
||||||
className="px-8 py-4 rounded-full border-2 border-white bg-white font-bold text-base text-slate-400"
|
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>
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
/* eslint-disable no-unused-vars */
|
||||||
import { cn } from '../utils/cn';
|
import * as React from "react";
|
||||||
import { ShieldCheck } from 'lucide-react';
|
import { cn } from "../utils/cn";
|
||||||
import { MonoLabel } from './Typography';
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import { MonoLabel } from "./Typography";
|
||||||
|
|
||||||
interface IframeSectionProps {
|
interface IframeSectionProps {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -28,7 +29,10 @@ interface IframeSectionProps {
|
|||||||
/**
|
/**
|
||||||
* Reusable Browser UI components to maintain consistency
|
* Reusable Browser UI components to maintain consistency
|
||||||
*/
|
*/
|
||||||
const BrowserChrome: React.FC<{ url: string; minimal?: boolean }> = ({ url, minimal }) => {
|
const BrowserChrome: React.FC<{ url: string; minimal?: boolean }> = ({
|
||||||
|
url,
|
||||||
|
minimal,
|
||||||
|
}) => {
|
||||||
if (minimal) return null;
|
if (minimal) return null;
|
||||||
return (
|
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">
|
<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">
|
||||||
@@ -73,7 +77,7 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
|||||||
rotate = 0,
|
rotate = 0,
|
||||||
delay = 0,
|
delay = 0,
|
||||||
noScale = false,
|
noScale = false,
|
||||||
dynamicGlow = true
|
dynamicGlow = true,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
||||||
@@ -81,13 +85,17 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
|||||||
const [scale, setScale] = React.useState(1);
|
const [scale, setScale] = React.useState(1);
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
const [glowColors, setGlowColors] = React.useState<string[]>([
|
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)",
|
||||||
'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
|
// Scaling Logic
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -100,7 +108,7 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
|||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
const currentWidth = containerRef.current.offsetWidth;
|
const currentWidth = containerRef.current.offsetWidth;
|
||||||
if (currentWidth > 0) {
|
if (currentWidth > 0) {
|
||||||
const newScale = zoom || (currentWidth / desktopWidth);
|
const newScale = zoom || currentWidth / desktopWidth;
|
||||||
setScale(newScale);
|
setScale(newScale);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,11 +125,12 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
|||||||
const doc = iframeRef.current?.contentDocument?.documentElement;
|
const doc = iframeRef.current?.contentDocument?.documentElement;
|
||||||
if (doc) {
|
if (doc) {
|
||||||
const atTop = doc.scrollTop <= 5;
|
const atTop = doc.scrollTop <= 5;
|
||||||
const atBottom = doc.scrollTop + doc.clientHeight >= doc.scrollHeight - 5;
|
const atBottom =
|
||||||
|
doc.scrollTop + doc.clientHeight >= doc.scrollHeight - 5;
|
||||||
const isScrollable = doc.scrollHeight > doc.clientHeight + 10;
|
const isScrollable = doc.scrollHeight > doc.clientHeight + 10;
|
||||||
setScrollState({ atTop, atBottom, isScrollable });
|
setScrollState({ atTop, atBottom, isScrollable });
|
||||||
}
|
}
|
||||||
} catch (e) { }
|
} catch (e) {}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Ambilight effect (sampled from iframe if same-origin)
|
// Ambilight effect (sampled from iframe if same-origin)
|
||||||
@@ -132,7 +141,7 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
|||||||
const doc = iframe.contentDocument;
|
const doc = iframe.contentDocument;
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
canvas.width = 100;
|
canvas.width = 100;
|
||||||
@@ -140,7 +149,7 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
|||||||
|
|
||||||
const body = doc.body;
|
const body = doc.body;
|
||||||
const computedStyle = window.getComputedStyle(body);
|
const computedStyle = window.getComputedStyle(body);
|
||||||
const bgColor = computedStyle.backgroundColor || 'rgba(255,255,255,1)';
|
const bgColor = computedStyle.backgroundColor || "rgba(255,255,255,1)";
|
||||||
|
|
||||||
const sampleX = (x: number, y: number) => {
|
const sampleX = (x: number, y: number) => {
|
||||||
const el = doc.elementFromPoint(x, y);
|
const el = doc.elementFromPoint(x, y);
|
||||||
@@ -155,49 +164,66 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
|||||||
sampleX(w / 2, sampleMargin + offsetY),
|
sampleX(w / 2, sampleMargin + offsetY),
|
||||||
sampleX(w - sampleMargin, h / 2 + offsetY),
|
sampleX(w - sampleMargin, h / 2 + offsetY),
|
||||||
sampleX(w / 2, h - sampleMargin + offsetY),
|
sampleX(w / 2, h - sampleMargin + offsetY),
|
||||||
sampleX(sampleMargin, h / 2 + offsetY)
|
sampleX(sampleMargin, h / 2 + offsetY),
|
||||||
];
|
];
|
||||||
|
|
||||||
setGlowColors(colors.map(c => {
|
setGlowColors(
|
||||||
if (!c || c === 'transparent') return 'rgba(148, 163, 184, 0.1)';
|
colors.map((c) => {
|
||||||
return c.replace('rgb(', 'rgba(').replace(')', ', 0.5)');
|
if (!c || c === "transparent") return "rgba(148, 163, 184, 0.1)";
|
||||||
}));
|
return c.replace("rgb(", "rgba(").replace(")", ", 0.5)");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
updateScrollState();
|
updateScrollState();
|
||||||
} catch (e) { }
|
} catch (e) {}
|
||||||
}, [dynamicGlow, offsetY, updateScrollState]);
|
}, [dynamicGlow, offsetY, updateScrollState]);
|
||||||
|
|
||||||
const headerHeightPx = (browserFrame && !minimal) ? 56 : 0;
|
const headerHeightPx = browserFrame && !minimal ? 56 : 0;
|
||||||
|
|
||||||
// Height parse helper
|
// Height parse helper
|
||||||
const parseNumericHeight = (h: string | number) => {
|
const parseNumericHeight = (h: string | number) => {
|
||||||
if (typeof h === 'number') return h;
|
if (typeof h === "number") return h;
|
||||||
const match = h.match(/^(\d+(?:\.\d+)?)(px)$/);
|
const match = h.match(/^(\d+(?:\.\d+)?)(px)$/);
|
||||||
return match ? parseFloat(match[1]) : null;
|
return match ? parseFloat(match[1]) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseNumericHeight = parseNumericHeight(height);
|
const baseNumericHeight = parseNumericHeight(height);
|
||||||
const finalScaledHeight = clipHeight
|
const finalScaledHeight = clipHeight
|
||||||
? (clipHeight * scale)
|
? clipHeight * scale
|
||||||
: (baseNumericHeight ? (baseNumericHeight * scale) : null);
|
: baseNumericHeight
|
||||||
|
? baseNumericHeight * scale
|
||||||
|
: null;
|
||||||
|
|
||||||
const chassisStyle = {
|
const chassisStyle = {
|
||||||
height: height === '100%'
|
height:
|
||||||
? '100%'
|
height === "100%"
|
||||||
: (finalScaledHeight ? `${finalScaledHeight + headerHeightPx}px` : `calc(${height} + ${headerHeightPx}px)`)
|
? "100%"
|
||||||
|
: finalScaledHeight
|
||||||
|
? `${finalScaledHeight + headerHeightPx}px`
|
||||||
|
: `calc(${height} + ${headerHeightPx}px)`,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("w-full group relative", !minimal && "space-y-6", className)}
|
className={cn(
|
||||||
style={className?.includes('h-full') ? { height: '100%' } : {}}
|
"w-full group relative",
|
||||||
|
!minimal && "space-y-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={className?.includes("h-full") ? { height: "100%" } : {}}
|
||||||
>
|
>
|
||||||
<canvas ref={canvasRef} className="hidden" aria-hidden="true" />
|
<canvas ref={canvasRef} className="hidden" aria-hidden="true" />
|
||||||
|
|
||||||
{!minimal && (title || description) && (
|
{!minimal && (title || description) && (
|
||||||
<div className="space-y-2 px-1">
|
<div className="space-y-2 px-1">
|
||||||
{title && <h4 className="text-2xl font-bold text-slate-900 tracking-tight leading-none">{title}</h4>}
|
{title && (
|
||||||
{description && <p className="text-slate-400 text-sm font-medium">{description}</p>}
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -207,9 +233,10 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
|||||||
className={cn(
|
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",
|
"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 ? "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)]",
|
!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",
|
perspective && "hover:scale-[1.03] hover:-translate-y-3",
|
||||||
"overflow-hidden"
|
"overflow-hidden",
|
||||||
)}
|
)}
|
||||||
style={chassisStyle}
|
style={chassisStyle}
|
||||||
>
|
>
|
||||||
@@ -225,14 +252,19 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
|||||||
radial-gradient(circle at 50% 90%, ${glowColors[2]} 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%)
|
radial-gradient(circle at 5% 50%, ${glowColors[3]} 0%, transparent 60%)
|
||||||
`,
|
`,
|
||||||
filter: 'saturate(2.2) brightness(1.1)'
|
filter: "saturate(2.2) brightness(1.1)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Browser Frame */}
|
{/* Browser Frame */}
|
||||||
{browserFrame && <BrowserChrome url="varnish-cache://secure.klz-cables.com" minimal={minimal} />}
|
{browserFrame && (
|
||||||
|
<BrowserChrome
|
||||||
|
url="varnish-cache://secure.klz-cables.com"
|
||||||
|
minimal={minimal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Scaled Viewport Container */}
|
{/* Scaled Viewport Container */}
|
||||||
<div className="flex-1 relative overflow-hidden bg-slate-50/50">
|
<div className="flex-1 relative overflow-hidden bg-slate-50/50">
|
||||||
@@ -241,19 +273,21 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
|||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60 backdrop-blur-xl z-50 transition-opacity duration-700">
|
<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="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" />
|
<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>
|
<MonoLabel className="text-[10px] text-slate-400 animate-pulse uppercase tracking-[0.2em]">
|
||||||
|
Establishing Connection
|
||||||
|
</MonoLabel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-1000 ease-[cubic-bezier(0.23,1,0.32,1)] origin-top-left absolute left-0 right-0",
|
"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"
|
noScale && "relative w-full h-full",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: noScale ? '100%' : `${desktopWidth}px`,
|
width: noScale ? "100%" : `${desktopWidth}px`,
|
||||||
transform: noScale ? 'none' : `scale(${scale})`,
|
transform: noScale ? "none" : `scale(${scale})`,
|
||||||
height: noScale ? '100%' : `${100 / scale}%`,
|
height: noScale ? "100%" : `${100 / scale}%`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<iframe
|
<iframe
|
||||||
@@ -262,14 +296,14 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
|||||||
scrolling={allowScroll ? "yes" : "no"}
|
scrolling={allowScroll ? "yes" : "no"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full border-none transition-all duration-700 no-scrollbar relative z-0",
|
"w-full border-none transition-all duration-700 no-scrollbar relative z-0",
|
||||||
isLoading ? "opacity-0 scale-95" : "opacity-100 scale-100"
|
isLoading ? "opacity-0 scale-95" : "opacity-100 scale-100",
|
||||||
)}
|
)}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
try {
|
try {
|
||||||
const iframe = e.currentTarget;
|
const iframe = e.currentTarget;
|
||||||
if (iframe.contentDocument) {
|
if (iframe.contentDocument) {
|
||||||
const style = iframe.contentDocument.createElement('style');
|
const style = iframe.contentDocument.createElement("style");
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
*::-webkit-scrollbar { display: none !important; }
|
*::-webkit-scrollbar { display: none !important; }
|
||||||
* { -ms-overflow-style: none !important; scrollbar-width: none !important; }
|
* { -ms-overflow-style: none !important; scrollbar-width: none !important; }
|
||||||
@@ -283,28 +317,40 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
|||||||
updateScrollState();
|
updateScrollState();
|
||||||
};
|
};
|
||||||
|
|
||||||
iframe.contentWindow?.addEventListener('scroll', onScroll, { passive: true });
|
iframe.contentWindow?.addEventListener("scroll", onScroll, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
iframe.contentWindow?.addEventListener('wheel', (e) => {
|
iframe.contentWindow?.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
(e) => {
|
||||||
const { deltaY } = e as WheelEvent;
|
const { deltaY } = e as WheelEvent;
|
||||||
const doc = iframe.contentDocument?.documentElement;
|
const doc = iframe.contentDocument?.documentElement;
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
const scrollTop = doc.scrollTop;
|
const scrollTop = doc.scrollTop;
|
||||||
const isAtTop = scrollTop <= 0;
|
const isAtTop = scrollTop <= 0;
|
||||||
const isAtBottom = scrollTop + doc.clientHeight >= doc.scrollHeight - 1;
|
const isAtBottom =
|
||||||
if ((isAtTop && deltaY < 0) || (isAtBottom && deltaY > 0)) {
|
scrollTop + doc.clientHeight >= doc.scrollHeight - 1;
|
||||||
window.scrollBy({ top: deltaY, behavior: 'auto' });
|
if (
|
||||||
|
(isAtTop && deltaY < 0) ||
|
||||||
|
(isAtBottom && deltaY > 0)
|
||||||
|
) {
|
||||||
|
window.scrollBy({ top: deltaY, behavior: "auto" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
} catch (_e) {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}, { passive: true });
|
|
||||||
} catch (err) { }
|
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
transform: `translateY(-${offsetY}px)`,
|
transform: `translateY(-${offsetY}px)`,
|
||||||
height: `calc(100% + ${offsetY}px)`,
|
height: `calc(100% + ${offsetY}px)`,
|
||||||
pointerEvents: allowScroll ? 'auto' : 'none',
|
pointerEvents: allowScroll ? "auto" : "none",
|
||||||
width: 'calc(100% + 20px)', // Bleed for seamless edge
|
width: "calc(100% + 20px)", // Bleed for seamless edge
|
||||||
marginLeft: '-10px'
|
marginLeft: "-10px",
|
||||||
}}
|
}}
|
||||||
title={title || "Project Display"}
|
title={title || "Project Display"}
|
||||||
/>
|
/>
|
||||||
@@ -316,24 +362,29 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
|||||||
<div
|
<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)]"
|
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={{
|
style={{
|
||||||
height: '30px',
|
height: "30px",
|
||||||
transform: `translateY(${(() => {
|
transform: `translateY(${(() => {
|
||||||
try {
|
try {
|
||||||
const doc = iframeRef.current?.contentDocument?.documentElement;
|
const doc =
|
||||||
|
iframeRef.current?.contentDocument?.documentElement;
|
||||||
if (!doc) return 0;
|
if (!doc) return 0;
|
||||||
const scrollPct = doc.scrollTop / (doc.scrollHeight - doc.clientHeight);
|
const scrollPct =
|
||||||
|
doc.scrollTop / (doc.scrollHeight - doc.clientHeight);
|
||||||
return scrollPct * (128 - 30);
|
return scrollPct * (128 - 30);
|
||||||
} catch (e) { return 0; }
|
} catch (e) {
|
||||||
})()}px)`
|
return 0;
|
||||||
|
}
|
||||||
|
})()}px)`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!allowScroll && <div className="absolute inset-x-0 bottom-0 top-14 pointer-events-auto cursor-default z-20" />}
|
{!allowScroll && (
|
||||||
|
<div className="absolute inset-x-0 bottom-0 top-14 pointer-events-auto cursor-default z-20" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
/* eslint-disable no-unused-vars */
|
||||||
import { motion } from 'framer-motion';
|
import * as React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
interface LineProps {
|
interface LineProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
delay?: number;
|
delay?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HeroLines: React.FC<LineProps> = ({ className = "", delay = 0 }) => {
|
export const HeroLines: React.FC<LineProps> = ({
|
||||||
|
className = "",
|
||||||
|
delay = 0,
|
||||||
|
}) => {
|
||||||
return (
|
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
|
<motion.path
|
||||||
d="M-100 300 C 100 300, 200 100, 400 100 C 600 100, 700 500, 900 500"
|
d="M-100 300 C 100 300, 200 100, 400 100 C 600 100, 700 500, 900 500"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -49,20 +58,48 @@ export const HeroLines: React.FC<LineProps> = ({ className = "", delay = 0 }) =>
|
|||||||
</motion.circle>
|
</motion.circle>
|
||||||
|
|
||||||
{/* Nodes */}
|
{/* Nodes */}
|
||||||
<motion.circle cx="400" cy="100" r="4" className="fill-slate-200"
|
<motion.circle
|
||||||
initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: delay + 1, duration: 0.5 }} />
|
cx="400"
|
||||||
<motion.circle cx="400" cy="150" r="4" className="fill-slate-100"
|
cy="100"
|
||||||
initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: delay + 1.2, duration: 0.5 }} />
|
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>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GridLines: React.FC<LineProps> = ({ className = "", delay = 0 }) => {
|
export const GridLines: React.FC<LineProps> = ({
|
||||||
|
className = "",
|
||||||
|
delay = 0,
|
||||||
|
}) => {
|
||||||
return (
|
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>
|
<defs>
|
||||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
<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>
|
</pattern>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||||
@@ -82,20 +119,52 @@ export const GridLines: React.FC<LineProps> = ({ className = "", delay = 0 }) =>
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Active Cells */}
|
{/* Active Cells */}
|
||||||
<motion.rect x="120" y="40" width="40" height="40" className="fill-slate-50"
|
<motion.rect
|
||||||
initial={{ opacity: 0 }} animate={{ opacity: [0, 0.5, 0] }} transition={{ duration: 3, repeat: Infinity, repeatDelay: 2 }} />
|
x="120"
|
||||||
<motion.rect x="160" y="80" width="40" height="40" className="fill-slate-50"
|
y="40"
|
||||||
initial={{ opacity: 0 }} animate={{ opacity: [0, 0.5, 0] }} transition={{ duration: 4, repeat: Infinity, repeatDelay: 1 }} />
|
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"
|
<motion.circle
|
||||||
initial={{ scale: 0 }} whileInView={{ scale: 1 }} viewport={{ once: true }} transition={{ delay: delay + 1.5 }} />
|
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>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FlowLines: React.FC<LineProps> = ({ className = "", delay = 0 }) => {
|
export const FlowLines: React.FC<LineProps> = ({
|
||||||
|
className = "",
|
||||||
|
delay = 0,
|
||||||
|
}) => {
|
||||||
return (
|
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
|
<motion.path
|
||||||
d="M 0 100 H 100 C 150 100, 150 50, 200 50 H 300"
|
d="M 0 100 H 100 C 150 100, 150 50, 200 50 H 300"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -126,35 +195,112 @@ export const FlowLines: React.FC<LineProps> = ({ className = "", delay = 0 }) =>
|
|||||||
/>
|
/>
|
||||||
</motion.circle>
|
</motion.circle>
|
||||||
|
|
||||||
<motion.rect x="300" y="30" width="80" height="40" rx="8" className="stroke-slate-300 fill-white" strokeWidth="1"
|
<motion.rect
|
||||||
initial={{ opacity: 0, x: 280 }} whileInView={{ opacity: 1, x: 300 }} viewport={{ once: true }} transition={{ delay: delay + 1 }} />
|
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"
|
<motion.rect
|
||||||
initial={{ opacity: 0, x: 280 }} whileInView={{ opacity: 1, x: 300 }} viewport={{ once: true }} transition={{ delay: delay + 1.2 }} />
|
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>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CirclePattern: React.FC<LineProps> = ({ className = "", delay = 0 }) => {
|
export const CirclePattern: React.FC<LineProps> = ({
|
||||||
|
className = "",
|
||||||
|
delay = 0,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<svg className={`absolute pointer-events-none ${className}`} viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
<motion.circle cx="200" cy="200" r="100" stroke="currentColor" strokeWidth="1" className="text-slate-100"
|
className={`absolute pointer-events-none ${className}`}
|
||||||
initial={{ scale: 0.8, opacity: 0 }} whileInView={{ scale: 1, opacity: 1 }} viewport={{ once: true }} transition={{ duration: 1, delay: delay }} />
|
viewBox="0 0 400 400"
|
||||||
<motion.circle cx="200" cy="200" r="150" stroke="currentColor" strokeWidth="1" className="text-slate-50"
|
fill="none"
|
||||||
initial={{ scale: 0.8, opacity: 0 }} whileInView={{ scale: 1, opacity: 1 }} viewport={{ once: true }} transition={{ duration: 1, delay: delay + 0.2 }} />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<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 }} />
|
<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 */}
|
{/* Rotating Ring */}
|
||||||
<motion.circle cx="200" cy="200" r="120" stroke="currentColor" strokeWidth="1" strokeDasharray="10 10" className="text-slate-200"
|
<motion.circle
|
||||||
animate={{ rotate: 360 }} transition={{ duration: 20, repeat: Infinity, ease: "linear" }} />
|
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>
|
</svg>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ServicesFlow: React.FC<LineProps> = ({ className = "", delay = 0 }) => {
|
export const ServicesFlow: React.FC<LineProps> = ({
|
||||||
|
className = "",
|
||||||
|
delay = 0,
|
||||||
|
}) => {
|
||||||
return (
|
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 1000 200"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
{/* Path connecting the 3 steps */}
|
{/* Path connecting the 3 steps */}
|
||||||
<motion.path
|
<motion.path
|
||||||
d="M 100 100 L 900 100"
|
d="M 100 100 L 900 100"
|
||||||
@@ -190,9 +336,17 @@ export const ServicesFlow: React.FC<LineProps> = ({ className = "", delay = 0 })
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ComparisonLines: React.FC<LineProps> = ({ className = "", delay = 0 }) => {
|
export const ComparisonLines: React.FC<LineProps> = ({
|
||||||
|
className = "",
|
||||||
|
delay = 0,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<svg className={`absolute pointer-events-none ${className}`} viewBox="0 0 100 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
|
className={`absolute pointer-events-none ${className}`}
|
||||||
|
viewBox="0 0 100 400"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
<motion.path
|
<motion.path
|
||||||
d="M 50 0 V 400"
|
d="M 50 0 V 400"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -205,17 +359,13 @@ export const ComparisonLines: React.FC<LineProps> = ({ className = "", delay = 0
|
|||||||
transition={{ duration: 1.5, delay: delay }}
|
transition={{ duration: 1.5, delay: delay }}
|
||||||
/>
|
/>
|
||||||
<motion.circle r="5" fill="currentColor" className="text-slate-900">
|
<motion.circle r="5" fill="currentColor" className="text-slate-900">
|
||||||
<animateMotion
|
<animateMotion dur="4s" repeatCount="indefinite" path="M 50 0 V 400" />
|
||||||
dur="4s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
path="M 50 0 V 400"
|
|
||||||
/>
|
|
||||||
</motion.circle>
|
</motion.circle>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ConnectorStart: React.FC<LineProps> = ({ className = "", delay = 0 }) => null;
|
export const ConnectorStart: React.FC<LineProps> = (_props) => null;
|
||||||
export const ConnectorBranch: React.FC<LineProps> = ({ className = "", delay = 0 }) => null;
|
export const ConnectorBranch: React.FC<LineProps> = (_props) => null;
|
||||||
export const ConnectorSplit: React.FC<LineProps> = ({ className = "", delay = 0 }) => null;
|
export const ConnectorSplit: React.FC<LineProps> = (_props) => null;
|
||||||
export const ConnectorEnd: React.FC<LineProps> = ({ className = "", delay = 0 }) => null;
|
export const ConnectorEnd: React.FC<LineProps> = (_props) => null;
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
/* eslint-disable no-unused-vars */
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { IllustrationProps } from './types';
|
|
||||||
|
|
||||||
export const ConceptAutomation: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
import * as React from "react";
|
||||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
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
|
<motion.g
|
||||||
animate={{ rotate: 360 }}
|
animate={{ rotate: 360 }}
|
||||||
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
|
||||||
@@ -22,7 +32,9 @@ export const ConceptAutomation: React.FC<IllustrationProps> = ({ className = "",
|
|||||||
</motion.g>
|
</motion.g>
|
||||||
<motion.path
|
<motion.path
|
||||||
d="M 10 60 H 110"
|
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"
|
strokeDasharray="4 4"
|
||||||
animate={{ strokeDashoffset: [0, -20] }}
|
animate={{ strokeDashoffset: [0, -20] }}
|
||||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
/* eslint-disable no-unused-vars */
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { IllustrationProps } from './types';
|
|
||||||
|
|
||||||
export const ConceptMessy: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
import * as React from "react";
|
||||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
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
|
<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"
|
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] }}
|
animate={{ strokeDashoffset: [0, 20] }}
|
||||||
strokeDasharray="4 4"
|
strokeDasharray="4 4"
|
||||||
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
|
||||||
/>
|
/>
|
||||||
<motion.path
|
<motion.path
|
||||||
d="M 20 40 L 100 80 M 20 80 L 100 40"
|
d="M 20 40 L 100 80 M 20 80 L 100 40"
|
||||||
stroke="currentColor" strokeWidth="1" className="text-slate-200 opacity-50"
|
stroke="currentColor"
|
||||||
animate={{ opacity: [0.2, 0.5, 0.2] }} transition={{ duration: 3, repeat: Infinity }}
|
strokeWidth="1"
|
||||||
|
className="text-slate-200 opacity-50"
|
||||||
|
animate={{ opacity: [0.2, 0.5, 0.2] }}
|
||||||
|
transition={{ duration: 3, repeat: Infinity }}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,27 +1,50 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
/* eslint-disable no-unused-vars */
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { IllustrationProps } from './types';
|
|
||||||
|
|
||||||
export const ConceptSystem: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
import * as React from "react";
|
||||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
import { motion } from "framer-motion";
|
||||||
<motion.circle cx="60" cy="60" r="15" className="fill-slate-900"
|
import { IllustrationProps } from "./types";
|
||||||
animate={{ scale: [1, 1.1, 1] }} transition={{ duration: 4, repeat: Infinity }} />
|
|
||||||
|
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) => {
|
{[0, 72, 144, 216, 288].map((angle, i) => {
|
||||||
const x = 60 + Math.cos((angle * Math.PI) / 180) * 40;
|
const x = 60 + Math.cos((angle * Math.PI) / 180) * 40;
|
||||||
const y = 60 + Math.sin((angle * Math.PI) / 180) * 40;
|
const y = 60 + Math.sin((angle * Math.PI) / 180) * 40;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={i}>
|
<React.Fragment key={i}>
|
||||||
<motion.line
|
<motion.line
|
||||||
x1="60" y1="60" x2={x} y2={y}
|
x1="60"
|
||||||
stroke="currentColor" strokeWidth="1" className="text-slate-400"
|
y1="60"
|
||||||
|
x2={x}
|
||||||
|
y2={y}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
className="text-slate-400"
|
||||||
animate={{ strokeDashoffset: [0, 10] }}
|
animate={{ strokeDashoffset: [0, 10] }}
|
||||||
strokeDasharray="2 2"
|
strokeDasharray="2 2"
|
||||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||||
/>
|
/>
|
||||||
<motion.circle
|
<motion.circle
|
||||||
cx={x} cy={y} r="6"
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
r="6"
|
||||||
className="fill-white stroke-slate-300"
|
className="fill-white stroke-slate-300"
|
||||||
animate={{ scale: [1, 1.2, 1] }}
|
animate={{ scale: [1, 1.2, 1] }}
|
||||||
transition={{ duration: 3, repeat: Infinity, delay: i * 0.4 }}
|
transition={{ duration: 3, repeat: Infinity, delay: i * 0.4 }}
|
||||||
|
|||||||
@@ -1,23 +1,43 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
/* eslint-disable no-unused-vars */
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { IllustrationProps } from './types';
|
|
||||||
|
|
||||||
export const ConceptTarget: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
import * as React from "react";
|
||||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
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
|
<motion.circle
|
||||||
cx="60" cy="60" r="50"
|
cx="60"
|
||||||
stroke="currentColor" strokeWidth="1" className="text-slate-300"
|
cy="60"
|
||||||
|
r="50"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
className="text-slate-300"
|
||||||
animate={{ scale: [1, 1.05, 1] }}
|
animate={{ scale: [1, 1.05, 1] }}
|
||||||
transition={{ duration: 4, repeat: Infinity }}
|
transition={{ duration: 4, repeat: Infinity }}
|
||||||
/>
|
/>
|
||||||
<motion.circle
|
<motion.circle
|
||||||
cx="60" cy="60" r="30"
|
cx="60"
|
||||||
stroke="currentColor" strokeWidth="1" className="text-slate-400"
|
cy="60"
|
||||||
|
r="30"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
className="text-slate-400"
|
||||||
/>
|
/>
|
||||||
<motion.circle
|
<motion.circle
|
||||||
cx="60" cy="60" r="10"
|
cx="60"
|
||||||
|
cy="60"
|
||||||
|
r="10"
|
||||||
className="fill-slate-900"
|
className="fill-slate-900"
|
||||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||||
transition={{ duration: 2, repeat: Infinity }}
|
transition={{ duration: 2, repeat: Infinity }}
|
||||||
|
|||||||
@@ -1,14 +1,37 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
/* eslint-disable no-unused-vars */
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { IllustrationProps } from './types';
|
|
||||||
|
|
||||||
export const ConceptWebsite: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
import * as React from "react";
|
||||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
import { motion } from "framer-motion";
|
||||||
<rect x="10" y="20" width="100" height="80" rx="4" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
|
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
|
<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"
|
className="fill-slate-200"
|
||||||
animate={{ opacity: [0.3, 0.6, 0.3] }}
|
animate={{ opacity: [0.3, 0.6, 0.3] }}
|
||||||
transition={{ duration: 3, repeat: Infinity }}
|
transition={{ duration: 3, repeat: Infinity }}
|
||||||
@@ -17,8 +40,22 @@ export const ConceptWebsite: React.FC<IllustrationProps> = ({ className = "", de
|
|||||||
animate={{ y: [0, 10, 0] }}
|
animate={{ y: [0, 10, 0] }}
|
||||||
transition={{ duration: 4, repeat: Infinity }}
|
transition={{ duration: 4, repeat: Infinity }}
|
||||||
>
|
>
|
||||||
<rect x="20" y="55" width="35" height="35" rx="2" className="fill-slate-300" />
|
<rect
|
||||||
<rect x="65" y="55" width="35" height="35" rx="2" className="fill-slate-300" />
|
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>
|
</motion.g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,29 +1,56 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
/* eslint-disable no-unused-vars */
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { IllustrationProps } from './types';
|
|
||||||
|
|
||||||
export const HeroArchitecture: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
import * as React from "react";
|
||||||
<svg className={className} viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
import { motion } from "framer-motion";
|
||||||
<motion.rect x="170" y="120" width="60" height="60" rx="8" className="stroke-slate-900 fill-white" strokeWidth="2"
|
import { IllustrationProps } from "./types";
|
||||||
animate={{ scale: [1, 1.05, 1] }} transition={{ duration: 4, repeat: Infinity }} />
|
|
||||||
|
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: 60 },
|
||||||
{ x: 80, y: 240 }, { x: 320, y: 240 }
|
{ x: 320, y: 60 },
|
||||||
|
{ x: 80, y: 240 },
|
||||||
|
{ x: 320, y: 240 },
|
||||||
].map((node, i) => (
|
].map((node, i) => (
|
||||||
<React.Fragment key={i}>
|
<React.Fragment key={i}>
|
||||||
<motion.path
|
<motion.path
|
||||||
d={`M 200 150 L ${node.x} ${node.y}`}
|
d={`M 200 150 L ${node.x} ${node.y}`}
|
||||||
stroke="currentColor" strokeWidth="1" className="text-slate-400"
|
stroke="currentColor"
|
||||||
animate={{ strokeDashoffset: [0, -10] }} strokeDasharray="4 4"
|
strokeWidth="1"
|
||||||
|
className="text-slate-400"
|
||||||
|
animate={{ strokeDashoffset: [0, -10] }}
|
||||||
|
strokeDasharray="4 4"
|
||||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||||
/>
|
/>
|
||||||
<motion.circle
|
<motion.circle
|
||||||
cx={node.x} cy={node.y} r="12"
|
cx={node.x}
|
||||||
|
cy={node.y}
|
||||||
|
r="12"
|
||||||
className="fill-white stroke-slate-300"
|
className="fill-white stroke-slate-300"
|
||||||
strokeWidth="1"
|
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>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
/* eslint-disable no-unused-vars */
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { IllustrationProps } from './types';
|
|
||||||
|
|
||||||
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 (
|
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 */}
|
{/* Matrix-style Binary Rain Background */}
|
||||||
<g className="opacity-[0.08]">
|
<g className="opacity-[0.08]">
|
||||||
{Array.from({ length: 20 }).map((_, col) => {
|
{Array.from({ length: 20 }).map((_, col) => {
|
||||||
const colX = 20 + col * 40;
|
const colX = 20 + col * 40;
|
||||||
const speed = 8 + (col % 6);
|
const speed = 8 + (col % 6);
|
||||||
const startDelay = (col % 5);
|
const startDelay = col % 5;
|
||||||
return (
|
return (
|
||||||
<motion.g
|
<motion.g
|
||||||
key={`rain-col-${col}`}
|
key={`rain-col-${col}`}
|
||||||
@@ -33,7 +43,7 @@ export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className =
|
|||||||
className="fill-slate-900 font-mono"
|
className="fill-slate-900 font-mono"
|
||||||
style={{ fontSize: 12 }}
|
style={{ fontSize: 12 }}
|
||||||
>
|
>
|
||||||
{(col + row) % 2 === 0 ? '1' : '0'}
|
{(col + row) % 2 === 0 ? "1" : "0"}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
</motion.g>
|
</motion.g>
|
||||||
@@ -46,35 +56,141 @@ export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className =
|
|||||||
animate={{ y: [0, 8, 0] }}
|
animate={{ y: [0, 8, 0] }}
|
||||||
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut" }}
|
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
|
||||||
<rect x="170" y="510" width="460" height="10" rx="2" className="fill-slate-200" />
|
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 */}
|
{/* 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>
|
</motion.g>
|
||||||
|
|
||||||
{/* Layer 2: Server/Database Layer */}
|
{/* Layer 2: Server/Database Layer */}
|
||||||
<motion.g
|
<motion.g
|
||||||
animate={{ y: [0, 6, 0] }}
|
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 */}
|
{/* Left Server Block */}
|
||||||
<g transform="translate(200, 400)">
|
<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
|
||||||
<rect x="10" y="10" width="100" height="15" rx="2" className="fill-slate-100" />
|
x="0"
|
||||||
<rect x="10" y="30" width="80" height="10" rx="2" className="fill-slate-200" />
|
y="0"
|
||||||
<rect x="10" y="45" width="60" height="10" rx="2" className="fill-slate-200" />
|
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" />
|
<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>
|
</g>
|
||||||
|
|
||||||
{/* Right Database Block */}
|
{/* Right Database Block */}
|
||||||
<g transform="translate(480, 400)">
|
<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
|
||||||
<rect x="10" y="10" width="100" height="15" rx="2" className="fill-slate-100" />
|
x="0"
|
||||||
<rect x="10" y="30" width="100" height="8" rx="2" className="fill-slate-200" />
|
y="0"
|
||||||
<rect x="10" y="42" width="100" height="8" rx="2" className="fill-slate-200" />
|
width="120"
|
||||||
<rect x="10" y="54" width="100" height="8" rx="2" className="fill-slate-200" />
|
height="80"
|
||||||
<text x="15" y="20" className="fill-slate-500 font-mono" style={{ fontSize: 8 }}>DATABASE</text>
|
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>
|
</g>
|
||||||
|
|
||||||
{/* Connection Lines */}
|
{/* Connection Lines */}
|
||||||
@@ -92,14 +208,40 @@ export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className =
|
|||||||
{/* Layer 3: Browser/Website */}
|
{/* Layer 3: Browser/Website */}
|
||||||
<motion.g
|
<motion.g
|
||||||
animate={{ y: [0, 4, 0] }}
|
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 */}
|
{/* 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 */}
|
{/* Browser Chrome */}
|
||||||
<rect x="180" y="100" width="440" height="30" rx="8" className="fill-slate-900" />
|
<rect
|
||||||
<rect x="180" y="120" width="440" height="10" className="fill-slate-900" />
|
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 */}
|
{/* Browser Dots */}
|
||||||
<circle cx="200" cy="115" r="5" className="fill-slate-600" />
|
<circle cx="200" cy="115" r="5" className="fill-slate-600" />
|
||||||
@@ -107,41 +249,186 @@ export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className =
|
|||||||
<circle cx="236" cy="115" r="5" className="fill-slate-600" />
|
<circle cx="236" cy="115" r="5" className="fill-slate-600" />
|
||||||
|
|
||||||
{/* Address Bar */}
|
{/* 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 */}
|
{/* Website Content */}
|
||||||
<g transform="translate(200, 150)">
|
<g transform="translate(200, 150)">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<rect x="0" y="0" width="400" height="20" className="fill-slate-50" />
|
<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
|
||||||
<rect x="280" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
|
x="10"
|
||||||
<rect x="320" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
|
y="5"
|
||||||
<rect x="360" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
|
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 */}
|
{/* Hero Section */}
|
||||||
<rect x="0" y="30" width="400" height="100" className="fill-slate-100" />
|
<rect
|
||||||
<rect x="20" y="50" width="180" height="16" rx="2" className="fill-slate-900" />
|
x="0"
|
||||||
<rect x="20" y="72" width="140" height="10" rx="2" className="fill-slate-400" />
|
y="30"
|
||||||
<rect x="20" y="88" width="100" height="10" rx="2" className="fill-slate-400" />
|
width="400"
|
||||||
<rect x="20" y="108" width="80" height="16" rx="4" className="fill-slate-900" />
|
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 */}
|
{/* Hero Image Placeholder */}
|
||||||
<rect x="240" y="40" width="140" height="80" rx="4" className="fill-slate-200" />
|
<rect
|
||||||
<path d="M 280 80 L 310 60 L 340 80 L 310 100 Z" className="fill-slate-300" />
|
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 */}
|
{/* Cards Section */}
|
||||||
<g transform="translate(0, 140)">
|
<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
|
||||||
<rect x="10" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
|
x="0"
|
||||||
<rect x="10" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
|
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
|
||||||
<rect x="147" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
|
x="137"
|
||||||
<rect x="147" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
|
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
|
||||||
<rect x="284" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
|
x="274"
|
||||||
<rect x="284" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
|
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>
|
||||||
</g>
|
</g>
|
||||||
</motion.g>
|
</motion.g>
|
||||||
@@ -176,7 +463,6 @@ export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className =
|
|||||||
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
|
||||||
/>
|
/>
|
||||||
</motion.g>
|
</motion.g>
|
||||||
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react';
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
/* eslint-disable no-unused-vars */
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
export interface IllustrationProps {
|
export interface IllustrationProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from "lucide-react";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import { Reveal } from './Reveal';
|
import { Reveal } from "./Reveal";
|
||||||
import { H1, LeadText } from './Typography';
|
import { H1, LeadText } from "./Typography";
|
||||||
import { cn } from '../utils/cn';
|
import { cn } from "../utils/cn";
|
||||||
|
|
||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
@@ -21,10 +21,15 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
|||||||
description,
|
description,
|
||||||
backLink,
|
backLink,
|
||||||
backgroundSymbol,
|
backgroundSymbol,
|
||||||
className = ""
|
className = "",
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
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 && (
|
{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">
|
<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}
|
{backgroundSymbol}
|
||||||
@@ -36,15 +41,14 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
|||||||
href={backLink.href}
|
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"
|
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>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-8 relative">
|
<div className="space-y-8 relative">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<H1 className="max-w-4xl">
|
<H1 className="max-w-4xl">{title}</H1>
|
||||||
{title}
|
|
||||||
</H1>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{description && (
|
{description && (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Text as PDFText,
|
Text as PDFText,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
} from "@react-pdf/renderer";
|
} from "@react-pdf/renderer";
|
||||||
import { DocumentTitle, Divider, COLORS, FONT_SIZES } from "../SharedUI";
|
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI";
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
section: { marginBottom: 32 },
|
section: { marginBottom: 32 },
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
/* eslint-disable no-unused-vars */
|
||||||
|
|
||||||
const DOCS_DIR = path.join(process.cwd(), "docs");
|
const DOCS_DIR = path.join(process.cwd(), "docs");
|
||||||
|
|
||||||
export function getTechDetails() {
|
export function getTechDetails() {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(path.join(DOCS_DIR, "TECH.md"), "utf-8");
|
const _content = fs.readFileSync(path.join(DOCS_DIR, "TECH.md"), "utf-8");
|
||||||
const sections = content.split("⸻").map((s) => s.trim());
|
const _sections = _content.split("⸻").map((s) => s.trim());
|
||||||
|
|
||||||
// Extract items (Speed, Responsive, Stability, etc.)
|
// Extract items (Speed, Responsive, Stability, etc.)
|
||||||
// Logic: Look for section headers and their summaries
|
// Logic: Look for section headers and their summaries
|
||||||
@@ -46,7 +48,7 @@ export function getTechDetails() {
|
|||||||
|
|
||||||
export function getPrinciples() {
|
export function getPrinciples() {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(
|
const _content = fs.readFileSync(
|
||||||
path.join(DOCS_DIR, "PRINCIPLES.md"),
|
path.join(DOCS_DIR, "PRINCIPLES.md"),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
@@ -86,7 +88,7 @@ export function getPrinciples() {
|
|||||||
|
|
||||||
export function getMaintenanceDetails() {
|
export function getMaintenanceDetails() {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(
|
const _content = fs.readFileSync(
|
||||||
path.join(DOCS_DIR, "MAINTENANCE.md"),
|
path.join(DOCS_DIR, "MAINTENANCE.md"),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
@@ -122,7 +124,7 @@ export function getMaintenanceDetails() {
|
|||||||
|
|
||||||
export function getStandardsDetails() {
|
export function getStandardsDetails() {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(
|
const _content = fs.readFileSync(
|
||||||
path.join(DOCS_DIR, "STANDARDS.md"),
|
path.join(DOCS_DIR, "STANDARDS.md"),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* Analytics interfaces - decoupled contracts
|
* Analytics interfaces - decoupled contracts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable no-unused-vars */
|
||||||
|
|
||||||
export interface AnalyticsEvent {
|
export interface AnalyticsEvent {
|
||||||
name: string;
|
name: string;
|
||||||
props?: Record<string, any>;
|
props?: Record<string, any>;
|
||||||
@@ -9,8 +11,8 @@ export interface AnalyticsEvent {
|
|||||||
|
|
||||||
export interface AnalyticsAdapter {
|
export interface AnalyticsAdapter {
|
||||||
track(event: AnalyticsEvent): Promise<void>;
|
track(event: AnalyticsEvent): Promise<void>;
|
||||||
identify?(userId: string, traits?: Record<string, any>): Promise<void>;
|
identify?(_userId: string, _traits?: Record<string, any>): Promise<void>;
|
||||||
page?(path: string, props?: Record<string, any>): Promise<void>;
|
page?(_path: string, _props?: Record<string, any>): Promise<void>;
|
||||||
getScriptTag?(): string;
|
getScriptTag?(): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
apps/web/src/utils/cache/file-adapter.ts
vendored
41
apps/web/src/utils/cache/file-adapter.ts
vendored
@@ -1,8 +1,10 @@
|
|||||||
import type { CacheAdapter, CacheConfig } from './interfaces';
|
import type { CacheAdapter, CacheConfig } from "./interfaces";
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from "node:fs/promises";
|
||||||
import * as path from 'node:path';
|
import * as path from "node:path";
|
||||||
import { existsSync, mkdirSync } from 'node:fs';
|
import { existsSync } from "node:fs";
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from "node:crypto";
|
||||||
|
|
||||||
|
/* eslint-disable no-unused-vars */
|
||||||
|
|
||||||
export class FileCacheAdapter implements CacheAdapter {
|
export class FileCacheAdapter implements CacheAdapter {
|
||||||
private cacheDir: string;
|
private cacheDir: string;
|
||||||
@@ -10,21 +12,24 @@ export class FileCacheAdapter implements CacheAdapter {
|
|||||||
private defaultTTL: number;
|
private defaultTTL: number;
|
||||||
|
|
||||||
constructor(config?: CacheConfig & { cacheDir?: string }) {
|
constructor(config?: CacheConfig & { cacheDir?: string }) {
|
||||||
this.cacheDir = config?.cacheDir || path.resolve(process.cwd(), '.cache');
|
this.cacheDir = config?.cacheDir || path.resolve(process.cwd(), ".cache");
|
||||||
this.prefix = config?.prefix || '';
|
this.prefix = config?.prefix || "";
|
||||||
this.defaultTTL = config?.defaultTTL || 3600;
|
this.defaultTTL = config?.defaultTTL || 3600;
|
||||||
|
|
||||||
if (!existsSync(this.cacheDir)) {
|
if (!existsSync(this.cacheDir)) {
|
||||||
fs.mkdir(this.cacheDir, { recursive: true }).catch(err => {
|
fs.mkdir(this.cacheDir, { recursive: true }).catch((err) => {
|
||||||
console.error(`Failed to create cache directory: ${this.cacheDir}`, err);
|
console.error(
|
||||||
|
`Failed to create cache directory: ${this.cacheDir}`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sanitize(key: string): string {
|
private sanitize(key: string): string {
|
||||||
const clean = key.replace(/[^a-z0-9]/gi, '_');
|
const clean = key.replace(/[^a-z0-9]/gi, "_");
|
||||||
if (clean.length > 64) {
|
if (clean.length > 64) {
|
||||||
return crypto.createHash('md5').update(key).digest('hex');
|
return crypto.createHash("md5").update(key).digest("hex");
|
||||||
}
|
}
|
||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
@@ -38,7 +43,7 @@ export class FileCacheAdapter implements CacheAdapter {
|
|||||||
const filePath = this.getFilePath(key);
|
const filePath = this.getFilePath(key);
|
||||||
try {
|
try {
|
||||||
if (!existsSync(filePath)) return null;
|
if (!existsSync(filePath)) return null;
|
||||||
const content = await fs.readFile(filePath, 'utf8');
|
const content = await fs.readFile(filePath, "utf8");
|
||||||
const data = JSON.parse(content);
|
const data = JSON.parse(content);
|
||||||
|
|
||||||
if (data.expiry && Date.now() > data.expiry) {
|
if (data.expiry && Date.now() > data.expiry) {
|
||||||
@@ -47,7 +52,7 @@ export class FileCacheAdapter implements CacheAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return data.value;
|
return data.value;
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,11 +63,11 @@ export class FileCacheAdapter implements CacheAdapter {
|
|||||||
const data = {
|
const data = {
|
||||||
value,
|
value,
|
||||||
expiry: effectiveTTL > 0 ? Date.now() + effectiveTTL * 1000 : null,
|
expiry: effectiveTTL > 0 ? Date.now() + effectiveTTL * 1000 : null,
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
|
await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to write cache file: ${filePath}`, error);
|
console.error(`Failed to write cache file: ${filePath}`, error);
|
||||||
}
|
}
|
||||||
@@ -74,7 +79,7 @@ export class FileCacheAdapter implements CacheAdapter {
|
|||||||
if (existsSync(filePath)) {
|
if (existsSync(filePath)) {
|
||||||
await fs.unlink(filePath);
|
await fs.unlink(filePath);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
// Ignore
|
// Ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,12 +89,12 @@ export class FileCacheAdapter implements CacheAdapter {
|
|||||||
if (existsSync(this.cacheDir)) {
|
if (existsSync(this.cacheDir)) {
|
||||||
const files = await fs.readdir(this.cacheDir);
|
const files = await fs.readdir(this.cacheDir);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.endsWith('.json')) {
|
if (file.endsWith(".json")) {
|
||||||
await fs.unlink(path.join(this.cacheDir, file));
|
await fs.unlink(path.join(this.cacheDir, file));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
// Ignore
|
// Ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
* This simulates what happens when a blog post is rendered
|
* This simulates what happens when a blog post is rendered
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { blogPosts } from '../data/blogPosts';
|
import { blogPosts } from "../data/blogPosts";
|
||||||
import { FileExampleManager } from '../data/fileExamples';
|
import { FileExampleManager } from "../data/fileExamples";
|
||||||
|
|
||||||
|
/* eslint-disable no-unused-vars */
|
||||||
|
|
||||||
export async function testBlogPostIntegration() {
|
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 passed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@@ -38,15 +40,15 @@ export async function testBlogPostIntegration() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Test 1: Blog posts exist
|
// Test 1: Blog posts exist
|
||||||
test('Blog posts are loaded', () => {
|
test("Blog posts are loaded", () => {
|
||||||
if (!blogPosts || blogPosts.length === 0) {
|
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`);
|
console.log(` Found ${blogPosts.length} posts`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 2: Each post has required fields
|
// Test 2: Each post has required fields
|
||||||
test('All posts have required fields', () => {
|
test("All posts have required fields", () => {
|
||||||
for (const post of blogPosts) {
|
for (const post of blogPosts) {
|
||||||
if (!post.slug || !post.title || !post.tags) {
|
if (!post.slug || !post.title || !post.tags) {
|
||||||
throw new Error(`Post ${post.slug} missing required fields`);
|
throw new Error(`Post ${post.slug} missing required fields`);
|
||||||
@@ -55,15 +57,21 @@ export async function testBlogPostIntegration() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Test 3: Debugging-tips post should have file examples
|
// Test 3: Debugging-tips post should have file examples
|
||||||
test('debugging-tips post has file examples', async () => {
|
test("debugging-tips post has file examples", async () => {
|
||||||
const post = blogPosts.find(p => p.slug === 'debugging-tips');
|
const post = blogPosts.find((p) => p.slug === "debugging-tips");
|
||||||
if (!post) {
|
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
|
// Check if it would trigger file examples
|
||||||
const showFileExamples = post.tags?.some(tag =>
|
const _showFileExamples = post.tags?.some((tag) =>
|
||||||
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
|
[
|
||||||
|
"architecture",
|
||||||
|
"design-patterns",
|
||||||
|
"system-design",
|
||||||
|
"docker",
|
||||||
|
"deployment",
|
||||||
|
].includes(tag),
|
||||||
);
|
);
|
||||||
|
|
||||||
// debugging-tips has tags ['debugging', 'tools'] so showFileExamples would be false
|
// debugging-tips has tags ['debugging', 'tools'] so showFileExamples would be false
|
||||||
@@ -71,169 +79,207 @@ export async function testBlogPostIntegration() {
|
|||||||
|
|
||||||
// Verify files exist for this post
|
// Verify files exist for this post
|
||||||
const groups = await FileExampleManager.getAllGroups();
|
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) {
|
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`);
|
console.log(` Found ${filesForPost.length} files for debugging-tips`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 4: Architecture-patterns post should have file examples
|
// Test 4: Architecture-patterns post should have file examples
|
||||||
test('architecture-patterns post has file examples', async () => {
|
test("architecture-patterns post has file examples", async () => {
|
||||||
const post = blogPosts.find(p => p.slug === 'architecture-patterns');
|
const post = blogPosts.find((p) => p.slug === "architecture-patterns");
|
||||||
if (!post) {
|
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
|
// Check if it would trigger file examples
|
||||||
const showFileExamples = post.tags?.some(tag =>
|
const showFileExamples = post.tags?.some((tag) =>
|
||||||
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
|
[
|
||||||
|
"architecture",
|
||||||
|
"design-patterns",
|
||||||
|
"system-design",
|
||||||
|
"docker",
|
||||||
|
"deployment",
|
||||||
|
].includes(tag),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!showFileExamples) {
|
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
|
// Verify files exist for this post
|
||||||
const groups = await FileExampleManager.getAllGroups();
|
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) {
|
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 5: Docker-deployment post should have file examples
|
||||||
test('docker-deployment post has file examples', async () => {
|
test("docker-deployment post has file examples", async () => {
|
||||||
const post = blogPosts.find(p => p.slug === 'docker-deployment');
|
const post = blogPosts.find((p) => p.slug === "docker-deployment");
|
||||||
if (!post) {
|
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
|
// Check if it would trigger file examples
|
||||||
const showFileExamples = post.tags?.some(tag =>
|
const showFileExamples = post.tags?.some((tag) =>
|
||||||
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
|
[
|
||||||
|
"architecture",
|
||||||
|
"design-patterns",
|
||||||
|
"system-design",
|
||||||
|
"docker",
|
||||||
|
"deployment",
|
||||||
|
].includes(tag),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!showFileExamples) {
|
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
|
// Verify files exist for this post
|
||||||
const groups = await FileExampleManager.getAllGroups();
|
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) {
|
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`);
|
console.log(` Found ${filesForPost.length} files for docker-deployment`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 6: First-note post should NOT have file examples
|
// Test 6: First-note post should NOT have file examples
|
||||||
test('first-note post has no file examples', async () => {
|
test("first-note post has no file examples", async () => {
|
||||||
const post = blogPosts.find(p => p.slug === 'first-note');
|
const post = blogPosts.find((p) => p.slug === "first-note");
|
||||||
if (!post) {
|
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
|
// Check if it would trigger file examples
|
||||||
const showFileExamples = post.tags?.some(tag =>
|
const showFileExamples = post.tags?.some((tag) =>
|
||||||
['architecture', 'design-patterns', 'system-design', 'docker', 'deployment'].includes(tag)
|
[
|
||||||
|
"architecture",
|
||||||
|
"design-patterns",
|
||||||
|
"system-design",
|
||||||
|
"docker",
|
||||||
|
"deployment",
|
||||||
|
].includes(tag),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (showFileExamples) {
|
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
|
// Verify no files exist for this post
|
||||||
const groups = await FileExampleManager.getAllGroups();
|
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) {
|
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`);
|
console.log(` Correctly has no files`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 7: Simulate FileExamplesList filtering for debugging-tips
|
// Test 7: Simulate FileExamplesList filtering for debugging-tips
|
||||||
test('FileExamplesList filtering works for debugging-tips', async () => {
|
test("FileExamplesList filtering works for debugging-tips", async () => {
|
||||||
const postSlug = 'debugging-tips';
|
const postSlug = "debugging-tips";
|
||||||
const groupId = 'python-data-processing';
|
const groupId = "python-data-processing";
|
||||||
|
|
||||||
const allGroups = await FileExampleManager.getAllGroups();
|
const allGroups = await FileExampleManager.getAllGroups();
|
||||||
const loadedGroups = allGroups
|
const loadedGroups = allGroups
|
||||||
.filter(g => g.groupId === groupId)
|
.filter((g) => g.groupId === groupId)
|
||||||
.map(g => ({
|
.map((g) => ({
|
||||||
...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) {
|
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) {
|
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`);
|
console.log(` Would show ${loadedGroups[0].files.length} files`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 8: Simulate FileExamplesList filtering for architecture-patterns
|
// Test 8: Simulate FileExamplesList filtering for architecture-patterns
|
||||||
test('FileExamplesList filtering works for architecture-patterns', async () => {
|
test("FileExamplesList filtering works for architecture-patterns", async () => {
|
||||||
const postSlug = 'architecture-patterns';
|
const postSlug = "architecture-patterns";
|
||||||
const tags = ['architecture', 'design-patterns', 'system-design'];
|
const tags = ["architecture", "design-patterns", "system-design"];
|
||||||
|
|
||||||
const allGroups = await FileExampleManager.getAllGroups();
|
const allGroups = await FileExampleManager.getAllGroups();
|
||||||
const loadedGroups = allGroups
|
const loadedGroups = allGroups
|
||||||
.map(g => ({
|
.map((g) => ({
|
||||||
...g,
|
...g,
|
||||||
files: g.files.filter(f => {
|
files: g.files.filter((f) => {
|
||||||
if (f.postSlug !== postSlug) return false;
|
if (f.postSlug !== postSlug) return false;
|
||||||
if (tags && tags.length > 0) {
|
if (tags && tags.length > 0) {
|
||||||
return f.tags?.some(tag => tags.includes(tag));
|
return f.tags?.some((tag) => tags.includes(tag));
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
})
|
}),
|
||||||
}))
|
}))
|
||||||
.filter(g => g.files.length > 0);
|
.filter((g) => g.files.length > 0);
|
||||||
|
|
||||||
if (loadedGroups.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);
|
const totalFiles = loadedGroups.reduce((sum, g) => sum + g.files.length, 0);
|
||||||
if (totalFiles === 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 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 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) {
|
if (filesWithoutPostSlug.length > 0) {
|
||||||
throw new Error(`${filesWithoutPostSlug.length} files missing postSlug`);
|
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 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 groups = await FileExampleManager.getAllGroups();
|
||||||
const filePostSlugs = new Set(groups.flatMap(g => g.files).map(f => f.postSlug));
|
const filePostSlugs = new Set(
|
||||||
const blogPostSlugs = new Set(blogPosts.map(p => p.slug));
|
groups.flatMap((g) => g.files).map((f) => f.postSlug),
|
||||||
|
);
|
||||||
|
const blogPostSlugs = new Set(blogPosts.map((p) => p.slug));
|
||||||
|
|
||||||
for (const slug of filePostSlugs) {
|
for (const slug of filePostSlugs) {
|
||||||
if (slug && !blogPostSlugs.has(slug)) {
|
if (slug && !blogPostSlugs.has(slug)) {
|
||||||
@@ -245,15 +291,19 @@ export async function testBlogPostIntegration() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Wait for async tests
|
// 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) {
|
if (failed === 0) {
|
||||||
console.log('🎉 All integration tests passed!');
|
console.log("🎉 All integration tests passed!");
|
||||||
console.log('\n✅ The file examples system is correctly integrated with blog posts!');
|
console.log(
|
||||||
|
"\n✅ The file examples system is correctly integrated with blog posts!",
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ Some integration tests failed');
|
console.log("❌ Some integration tests failed");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
* Comprehensive tests for the file examples system
|
* 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
|
// Test helper to run all tests
|
||||||
export async function runFileExamplesTests() {
|
export async function runFileExamplesTests() {
|
||||||
console.log('🧪 Running File Examples System Tests...\n');
|
console.log("🧪 Running File Examples System Tests...\n");
|
||||||
|
|
||||||
let passed = 0;
|
let passed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@@ -37,62 +41,62 @@ export async function runFileExamplesTests() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Test 1: Data structure exists
|
// Test 1: Data structure exists
|
||||||
test('File examples data is loaded', () => {
|
test("File examples data is loaded", () => {
|
||||||
if (!sampleFileExamples || sampleFileExamples.length === 0) {
|
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`);
|
console.log(` Found ${sampleFileExamples.length} groups`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 2: FileExampleManager exists
|
// Test 2: FileExampleManager exists
|
||||||
test('FileExampleManager class is available', () => {
|
test("FileExampleManager class is available", () => {
|
||||||
if (!FileExampleManager) {
|
if (!FileExampleManager) {
|
||||||
throw new Error('FileExampleManager not found');
|
throw new Error("FileExampleManager not found");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 3: Sample data has correct structure
|
// Test 3: Sample data has correct structure
|
||||||
test('Sample data has correct structure', () => {
|
test("Sample data has correct structure", () => {
|
||||||
const group = sampleFileExamples[0];
|
const group = sampleFileExamples[0];
|
||||||
if (!group.groupId || !group.title || !Array.isArray(group.files)) {
|
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];
|
const file = group.files[0];
|
||||||
if (!file.id || !file.filename || !file.content || !file.language) {
|
if (!file.id || !file.filename || !file.content || !file.language) {
|
||||||
throw new Error('Invalid file structure');
|
throw new Error("Invalid file structure");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for postSlug
|
// Check for postSlug
|
||||||
if (!file.postSlug) {
|
if (!file.postSlug) {
|
||||||
throw new Error('Files missing postSlug property');
|
throw new Error("Files missing postSlug property");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 4: Get all groups
|
// Test 4: Get all groups
|
||||||
test('FileExampleManager.getAllGroups() works', async () => {
|
test("FileExampleManager.getAllGroups() works", async () => {
|
||||||
const groups = await FileExampleManager.getAllGroups();
|
const groups = await FileExampleManager.getAllGroups();
|
||||||
if (!Array.isArray(groups) || groups.length === 0) {
|
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 5: Get specific group
|
||||||
test('FileExampleManager.getGroup() works', async () => {
|
test("FileExampleManager.getGroup() works", async () => {
|
||||||
const group = await FileExampleManager.getGroup('python-data-processing');
|
const group = await FileExampleManager.getGroup("python-data-processing");
|
||||||
if (!group) {
|
if (!group) {
|
||||||
throw new Error('Group not found');
|
throw new Error("Group not found");
|
||||||
}
|
}
|
||||||
if (group.groupId !== 'python-data-processing') {
|
if (group.groupId !== "python-data-processing") {
|
||||||
throw new Error('Wrong group returned');
|
throw new Error("Wrong group returned");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 6: Search files
|
// Test 6: Search files
|
||||||
test('FileExampleManager.searchFiles() works', async () => {
|
test("FileExampleManager.searchFiles() works", async () => {
|
||||||
const results = await FileExampleManager.searchFiles('python');
|
const results = await FileExampleManager.searchFiles("python");
|
||||||
if (!Array.isArray(results)) {
|
if (!Array.isArray(results)) {
|
||||||
throw new Error('searchFiles returned invalid result');
|
throw new Error("searchFiles returned invalid result");
|
||||||
}
|
}
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
throw new Error('No results found for "python"');
|
throw new Error('No results found for "python"');
|
||||||
@@ -100,163 +104,178 @@ export async function runFileExamplesTests() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Test 7: Get file by ID
|
// Test 7: Get file by ID
|
||||||
test('FileExampleManager.getFileExample() works', async () => {
|
test("FileExampleManager.getFileExample() works", async () => {
|
||||||
const file = await FileExampleManager.getFileExample('python-data-processor');
|
const file = await FileExampleManager.getFileExample(
|
||||||
|
"python-data-processor",
|
||||||
|
);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
throw new Error('File not found');
|
throw new Error("File not found");
|
||||||
}
|
}
|
||||||
if (file.id !== 'python-data-processor') {
|
if (file.id !== "python-data-processor") {
|
||||||
throw new Error('Wrong file returned');
|
throw new Error("Wrong file returned");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 8: Filter by postSlug
|
// Test 8: Filter by postSlug
|
||||||
test('Filter files by postSlug', async () => {
|
test("Filter files by postSlug", async () => {
|
||||||
const groups = await FileExampleManager.getAllGroups();
|
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) {
|
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`);
|
console.log(` Found ${debuggingFiles.length} files for debugging-tips`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 9: Filter by postSlug and groupId
|
// 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 groups = await FileExampleManager.getAllGroups();
|
||||||
const filtered = groups
|
const filtered = groups
|
||||||
.filter(g => g.groupId === 'python-data-processing')
|
.filter((g) => g.groupId === "python-data-processing")
|
||||||
.map(g => ({
|
.map((g) => ({
|
||||||
...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) {
|
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`);
|
console.log(` Found ${filtered[0].files.length} files`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 10: Filter by postSlug and tags
|
// 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 groups = await FileExampleManager.getAllGroups();
|
||||||
const tags = ['architecture', 'design-patterns'];
|
const tags = ["architecture", "design-patterns"];
|
||||||
|
|
||||||
const filtered = groups
|
const filtered = groups
|
||||||
.map(g => ({
|
.map((g) => ({
|
||||||
...g,
|
...g,
|
||||||
files: g.files.filter(f =>
|
files: g.files.filter(
|
||||||
f.postSlug === 'architecture-patterns' &&
|
(f) =>
|
||||||
f.tags?.some(tag => tags.includes(tag))
|
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) {
|
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`);
|
console.log(` Found ${filtered[0].files.length} files`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 11: Download single file
|
// Test 11: Download single file
|
||||||
test('Download single file', async () => {
|
test("Download single file", async () => {
|
||||||
const result = await FileExampleManager.downloadFile('python-data-processor');
|
const result = await FileExampleManager.downloadFile(
|
||||||
|
"python-data-processor",
|
||||||
|
);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error('Download failed');
|
throw new Error("Download failed");
|
||||||
}
|
}
|
||||||
if (!result.filename || !result.content || !result.mimeType) {
|
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 12: Download multiple files
|
||||||
test('Download multiple files', async () => {
|
test("Download multiple files", async () => {
|
||||||
const files = await FileExampleManager.downloadMultiple(['python-data-processor', 'python-config-example']);
|
const files = await FileExampleManager.downloadMultiple([
|
||||||
|
"python-data-processor",
|
||||||
|
"python-config-example",
|
||||||
|
]);
|
||||||
if (!Array.isArray(files) || files.length !== 2) {
|
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 13: Get available tags
|
||||||
test('Get available tags', async () => {
|
test("Get available tags", async () => {
|
||||||
const tags = await FileExampleManager.getAvailableTags();
|
const tags = await FileExampleManager.getAvailableTags();
|
||||||
if (!Array.isArray(tags) || tags.length === 0) {
|
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')) {
|
if (!tags.includes("python") || !tags.includes("architecture")) {
|
||||||
throw new Error('Expected tags not found');
|
throw new Error("Expected tags not found");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 14: Create new file example
|
// Test 14: Create new file example
|
||||||
test('Create new file example', async () => {
|
test("Create new file example", async () => {
|
||||||
const newExample = await FileExampleManager.createFileExample({
|
const newExample = await FileExampleManager.createFileExample({
|
||||||
filename: 'test.py',
|
filename: "test.py",
|
||||||
content: 'print("test")',
|
content: 'print("test")',
|
||||||
language: 'python',
|
language: "python",
|
||||||
description: 'Test file',
|
description: "Test file",
|
||||||
tags: ['test'],
|
tags: ["test"],
|
||||||
postSlug: 'test-post'
|
postSlug: "test-post",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!newExample.id) {
|
if (!newExample.id) {
|
||||||
throw new Error('New example has no ID');
|
throw new Error("New example has no ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify it was added
|
// Verify it was added
|
||||||
const retrieved = await FileExampleManager.getFileExample(newExample.id);
|
const retrieved = await FileExampleManager.getFileExample(newExample.id);
|
||||||
if (!retrieved || retrieved.filename !== 'test.py') {
|
if (!retrieved || retrieved.filename !== "test.py") {
|
||||||
throw new Error('New example not found');
|
throw new Error("New example not found");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 15: Update file example
|
// Test 15: Update file example
|
||||||
test('Update file example', async () => {
|
test("Update file example", async () => {
|
||||||
const updated = await FileExampleManager.updateFileExample('python-data-processor', {
|
const updated = await FileExampleManager.updateFileExample(
|
||||||
description: 'Updated description'
|
"python-data-processor",
|
||||||
});
|
{
|
||||||
|
description: "Updated description",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!updated || updated.description !== 'Updated description') {
|
if (!updated || updated.description !== "Updated description") {
|
||||||
throw new Error('Update failed');
|
throw new Error("Update failed");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 16: Delete file example
|
// Test 16: Delete file example
|
||||||
test('Delete file example', async () => {
|
test("Delete file example", async () => {
|
||||||
// First create one
|
// First create one
|
||||||
const created = await FileExampleManager.createFileExample({
|
const created = await FileExampleManager.createFileExample({
|
||||||
filename: 'delete-test.py',
|
filename: "delete-test.py",
|
||||||
content: 'test',
|
content: "test",
|
||||||
language: 'python',
|
language: "python",
|
||||||
postSlug: 'test'
|
postSlug: "test",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Then delete it
|
// Then delete it
|
||||||
const deleted = await FileExampleManager.deleteFileExample(created.id);
|
const deleted = await FileExampleManager.deleteFileExample(created.id);
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
throw new Error('Delete failed');
|
throw new Error("Delete failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify it's gone
|
// Verify it's gone
|
||||||
const retrieved = await FileExampleManager.getFileExample(created.id);
|
const retrieved = await FileExampleManager.getFileExample(created.id);
|
||||||
if (retrieved) {
|
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
|
// 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`);
|
console.log(`\n📊 Test Results: ${passed} passed, ${failed} failed`);
|
||||||
|
|
||||||
if (failed === 0) {
|
if (failed === 0) {
|
||||||
console.log('🎉 All tests passed!');
|
console.log("🎉 All tests passed!");
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ Some tests failed');
|
console.log("❌ Some tests failed");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
AbsoluteFill,
|
AbsoluteFill,
|
||||||
interpolate,
|
interpolate,
|
||||||
@@ -6,31 +6,38 @@ import {
|
|||||||
useVideoConfig,
|
useVideoConfig,
|
||||||
Easing,
|
Easing,
|
||||||
Img,
|
Img,
|
||||||
staticFile,
|
|
||||||
spring,
|
spring,
|
||||||
random,
|
} from "remotion";
|
||||||
} from 'remotion';
|
import { MouseCursor } from "../components/MouseCursor";
|
||||||
import { MouseCursor } from '../components/MouseCursor';
|
import { Button } from "@/src/components/Button";
|
||||||
import { Button } from '@/src/components/Button';
|
import { Loader2, Check, ShieldCheck } from "lucide-react";
|
||||||
import { Loader2, Check, UserCheck, ShieldCheck } from 'lucide-react';
|
|
||||||
|
/* eslint-disable no-unused-vars */
|
||||||
|
|
||||||
// Import logo using the alias setup in remotion.config.ts
|
// 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
|
import IconBlack from "@/src/assets/logo/Icon Black Transparent.svg";
|
||||||
// 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';
|
|
||||||
|
|
||||||
const Background: React.FC<{ loadingOpacity: number }> = ({ loadingOpacity }) => {
|
const Background: React.FC<{ loadingOpacity: number }> = ({
|
||||||
|
loadingOpacity,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<AbsoluteFill className="bg-white">
|
<AbsoluteFill className="bg-white">
|
||||||
{/* Website-Matching Grid */}
|
{/* Website-Matching Grid */}
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id="lightGrid" width="40" height="40" patternUnits="userSpaceOnUse">
|
<pattern
|
||||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#f1f5f9" strokeWidth="1" />
|
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>
|
</pattern>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="100%" height="100%" fill="url(#lightGrid)" />
|
<rect width="100%" height="100%" fill="url(#lightGrid)" />
|
||||||
@@ -56,8 +63,12 @@ const Background: React.FC<{ loadingOpacity: number }> = ({ loadingOpacity }) =>
|
|||||||
<Img src={IconBlack} className="w-8 h-8 opacity-90" />
|
<Img src={IconBlack} className="w-8 h-8 opacity-90" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col opacity-80">
|
<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-900 font-sans font-bold text-lg tracking-tight leading-none">
|
||||||
<span className="text-slate-400 font-serif italic text-xs mt-1">Component Library</span>
|
Mintel.me
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400 font-serif italic text-xs mt-1">
|
||||||
|
Component Library
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,27 +77,29 @@ const Background: React.FC<{ loadingOpacity: number }> = ({ loadingOpacity }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Toast Notification Component
|
// Toast Notification Component
|
||||||
const Toast: React.FC<{ show: boolean; text: string }> = ({ show, text }) => {
|
const Toast: React.FC<{ _show: boolean; _text: string }> = ({
|
||||||
const frame = useCurrentFrame();
|
_show,
|
||||||
const { fps } = useVideoConfig();
|
_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 (
|
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="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">
|
<div className="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center text-green-400">
|
||||||
<ShieldCheck size={18} />
|
<ShieldCheck size={18} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-bold text-sm tracking-wide">Authentication Successful</span>
|
<span className="font-bold text-sm tracking-wide">
|
||||||
<span className="text-slate-400 text-xs font-medium">Access granted to secure portal</span>
|
Authentication Successful
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400 text-xs font-medium">
|
||||||
|
Access granted to secure portal
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ButtonShowcase: React.FC = () => {
|
export const ButtonShowcase: React.FC = () => {
|
||||||
const frame = useCurrentFrame();
|
const frame = useCurrentFrame();
|
||||||
@@ -113,7 +126,9 @@ export const ButtonShowcase: React.FC = () => {
|
|||||||
|
|
||||||
// Approach
|
// Approach
|
||||||
if (f < HOVER_START) {
|
if (f < HOVER_START) {
|
||||||
const t = interpolate(f, [ENTER_START, HOVER_START], [0, 1], { extrapolateRight: 'clamp' });
|
const t = interpolate(f, [ENTER_START, HOVER_START], [0, 1], {
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
const ease = Easing.bezier(0.16, 1, 0.3, 1)(t);
|
const ease = Easing.bezier(0.16, 1, 0.3, 1)(t);
|
||||||
const x = interpolate(ease, [0, 1], [startX, targetX]);
|
const x = interpolate(ease, [0, 1], [startX, targetX]);
|
||||||
const y = interpolate(ease, [0, 1], [startY, targetY]);
|
const y = interpolate(ease, [0, 1], [startY, targetY]);
|
||||||
@@ -127,19 +142,24 @@ export const ButtonShowcase: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Exit
|
// Exit
|
||||||
const t = interpolate(f, [EXIT_START, EXIT_START + 30], [0, 1], { extrapolateLeft: 'clamp' });
|
const t = interpolate(f, [EXIT_START, EXIT_START + 30], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
});
|
||||||
const ease = Easing.exp(t);
|
const ease = Easing.exp(t);
|
||||||
return {
|
return {
|
||||||
x: interpolate(ease, [0, 1], [targetX, width * 0.9]),
|
x: interpolate(ease, [0, 1], [targetX, width * 0.9]),
|
||||||
y: interpolate(ease, [0, 1], [targetY, -100]),
|
y: interpolate(ease, [0, 1], [targetY, -100]),
|
||||||
vx: 10
|
vx: 10,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const { x: mouseX, y: mouseY, vx } = getMousePos(frame);
|
const { x: mouseX, y: mouseY, vx } = getMousePos(frame);
|
||||||
|
|
||||||
// 3D Cursor Skew
|
// 3D Cursor Skew
|
||||||
const cursorSkew = interpolate(vx, [-20, 20], [20, -20], { extrapolateRight: 'clamp', extrapolateLeft: 'clamp' });
|
const cursorSkew = interpolate(vx, [-20, 20], [20, -20], {
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
});
|
||||||
const isClicking = frame >= CLICK_FRAME && frame < CLICK_FRAME + 8;
|
const isClicking = frame >= CLICK_FRAME && frame < CLICK_FRAME + 8;
|
||||||
const clickRotate = isClicking ? 30 : 0;
|
const clickRotate = isClicking ? 30 : 0;
|
||||||
|
|
||||||
@@ -148,39 +168,61 @@ export const ButtonShowcase: React.FC = () => {
|
|||||||
const isSuccess = frame >= SUCCESS_START;
|
const isSuccess = frame >= SUCCESS_START;
|
||||||
|
|
||||||
// Loading Spinner Rotation
|
// Loading Spinner Rotation
|
||||||
const spinnerRot = interpolate(frame, [LOADING_START, SUCCESS_START], [0, 720]);
|
const spinnerRot = interpolate(
|
||||||
|
frame,
|
||||||
|
[LOADING_START, SUCCESS_START],
|
||||||
|
[0, 720],
|
||||||
|
);
|
||||||
|
|
||||||
// Button Scale Physics
|
// Button Scale Physics
|
||||||
const pressSpring = spring({ frame: frame - CLICK_FRAME, fps, config: { stiffness: 400, damping: 20 } });
|
const pressSpring = spring({
|
||||||
const successSpring = spring({ frame: frame - SUCCESS_START, fps, config: { stiffness: 200, damping: 15 } });
|
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)
|
// Morph scale: Click(Compress) -> Loading(Normal) -> Success(Pop)
|
||||||
let buttonScale = 1;
|
let buttonScale = 1;
|
||||||
if (frame >= CLICK_FRAME && frame < LOADING_START) {
|
if (frame >= CLICK_FRAME && frame < LOADING_START) {
|
||||||
buttonScale = 1 - (pressSpring * 0.05);
|
buttonScale = 1 - pressSpring * 0.05;
|
||||||
} else if (isSuccess) {
|
} else if (isSuccess) {
|
||||||
buttonScale = 1 + (successSpring * 0.05);
|
buttonScale = 1 + successSpring * 0.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Button Width Morph (Optional: Make it circle on load? Keeping it wide for consistency is safer)
|
|
||||||
|
|
||||||
// 3. Toast Animation
|
// 3. Toast Animation
|
||||||
const toastSpring = spring({ frame: frame - TOAST_START, fps, config: { stiffness: 100, damping: 15 } });
|
const toastSpring = spring({
|
||||||
const toastExit = spring({ frame: frame - TOAST_END, fps, config: { stiffness: 100, damping: 20 } });
|
frame: frame - TOAST_START,
|
||||||
const toastY = interpolate(toastSpring, [0, 1], [100, -80]) + interpolate(toastExit, [0, 1], [0, 200]);
|
fps,
|
||||||
const toastOpacity = interpolate(toastSpring, [0, 1], [0, 1]) - interpolate(toastExit, [0, 0.5], [0, 1]);
|
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 (
|
return (
|
||||||
<AbsoluteFill className="items-center justify-center overflow-hidden bg-white">
|
<AbsoluteFill className="items-center justify-center overflow-hidden bg-white">
|
||||||
<Background loadingOpacity={isLoading ? 1 : 0} />
|
<Background loadingOpacity={isLoading ? 1 : 0} />
|
||||||
|
|
||||||
{/* Main Stage */}
|
{/* Main Stage */}
|
||||||
<div style={{ perspective: '1000px', zIndex: 10 }}>
|
<div style={{ perspective: "1000px", zIndex: 10 }}>
|
||||||
{/* Button Container */}
|
{/* Button Container */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
transform: `scale(${buttonScale})`,
|
transform: `scale(${buttonScale})`,
|
||||||
transition: 'transform 0.1s'
|
transition: "transform 0.1s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -190,9 +232,10 @@ export const ButtonShowcase: React.FC = () => {
|
|||||||
className={`
|
className={`
|
||||||
!transition-all !duration-500
|
!transition-all !duration-500
|
||||||
!px-16 !py-8 !text-2xl !font-bold
|
!px-16 !py-8 !text-2xl !font-bold
|
||||||
${isSuccess
|
${
|
||||||
? '!bg-white !text-slate-900 !border-slate-900 shadow-none' // Success: Outline/Minimal
|
isSuccess
|
||||||
: '!bg-slate-900 !text-white !border-none !shadow-2xl !shadow-slate-300' // Default/Load: Solid
|
? "!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
|
!rounded-full
|
||||||
`}
|
`}
|
||||||
@@ -231,20 +274,21 @@ export const ButtonShowcase: React.FC = () => {
|
|||||||
className="absolute bottom-0 left-0 right-0 flex justify-center pb-20"
|
className="absolute bottom-0 left-0 right-0 flex justify-center pb-20"
|
||||||
style={{
|
style={{
|
||||||
transform: `translateY(${toastY}px)`,
|
transform: `translateY(${toastY}px)`,
|
||||||
opacity: Math.max(0, toastOpacity)
|
opacity: Math.max(0, toastOpacity),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Toast show={true} text="" />
|
<Toast _show={true} _text="" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3D Cursor */}
|
{/* 3D Cursor */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 0, left: 0,
|
top: 0,
|
||||||
|
left: 0,
|
||||||
transform: `translate(${mouseX}px, ${mouseY}px) skewX(${cursorSkew}deg) rotateX(${clickRotate}deg)`,
|
transform: `translate(${mouseX}px, ${mouseY}px) skewX(${cursorSkew}deg) rotateX(${clickRotate}deg)`,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
pointerEvents: 'none'
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MouseCursor x={0} y={0} isClicking={isClicking} />
|
<MouseCursor x={0} y={0} isClicking={isClicking} />
|
||||||
|
|||||||
@@ -1,38 +1,40 @@
|
|||||||
import React, { useMemo, useEffect, useState } from 'react';
|
import React, { useMemo, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AbsoluteFill,
|
AbsoluteFill,
|
||||||
interpolate,
|
interpolate,
|
||||||
useCurrentFrame,
|
useCurrentFrame,
|
||||||
useVideoConfig,
|
useVideoConfig,
|
||||||
Easing,
|
|
||||||
Img,
|
|
||||||
delayRender,
|
delayRender,
|
||||||
continueRender,
|
continueRender,
|
||||||
spring,
|
spring,
|
||||||
Audio,
|
Img,
|
||||||
staticFile,
|
} from "remotion";
|
||||||
} from 'remotion';
|
|
||||||
import { MouseCursor } from '../components/MouseCursor';
|
/* eslint-disable no-unused-vars */
|
||||||
import { ContactForm } from '@/src/components/ContactForm';
|
import { MouseCursor } from "../components/MouseCursor";
|
||||||
import { BackgroundGrid } from '@/src/components/Layout';
|
import { ContactForm } from "@/src/components/ContactForm";
|
||||||
import { initialState } from '@/src/components/ContactForm/constants';
|
import { BackgroundGrid } from "@/src/components/Layout";
|
||||||
|
import { initialState } from "@/src/components/ContactForm/constants";
|
||||||
|
|
||||||
// Brand Assets
|
// 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 = () => {
|
export const ContactFormShowcase: React.FC = () => {
|
||||||
const frame = useCurrentFrame();
|
const frame = useCurrentFrame();
|
||||||
const { width, height, fps } = useVideoConfig();
|
const { width, height, fps } = useVideoConfig();
|
||||||
const [handle] = useState(() => delayRender('Initializing Deep Interaction Script'));
|
const [handle] = useState(() =>
|
||||||
|
delayRender("Initializing Deep Interaction Script"),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') (window as any).isRemotion = true;
|
if (typeof window !== "undefined") (window as any).isRemotion = true;
|
||||||
const timer = setTimeout(() => continueRender(handle), 500);
|
const timer = setTimeout(() => continueRender(handle), 500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [handle]);
|
}, [handle]);
|
||||||
|
|
||||||
// ---- TIMELINE CONSTANTS (1500 Frames / 25s) ----
|
// ---- TIMELINE CONSTANTS (1500 Frames / 25s) ----
|
||||||
const T = useMemo(() => ({
|
const T = useMemo(
|
||||||
|
() => ({
|
||||||
ENTER: 0,
|
ENTER: 0,
|
||||||
// Step 0: Type Selection
|
// Step 0: Type Selection
|
||||||
SELECT_WEBSITE: 60,
|
SELECT_WEBSITE: 60,
|
||||||
@@ -68,51 +70,79 @@ export const ContactFormShowcase: React.FC = () => {
|
|||||||
SUBMIT: 1450,
|
SUBMIT: 1450,
|
||||||
|
|
||||||
EXIT: 1500,
|
EXIT: 1500,
|
||||||
}), []);
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// ---- FORM STATE LOGIC ----
|
// ---- FORM STATE LOGIC ----
|
||||||
const formState = useMemo(() => {
|
const formState = useMemo(() => {
|
||||||
const state = { ...initialState };
|
const state = { ...initialState };
|
||||||
|
|
||||||
// Step 0: Fixed to website per request
|
// Step 0: Fixed to website per request
|
||||||
state.projectType = 'website';
|
state.projectType = "website";
|
||||||
|
|
||||||
// Step 1: Company
|
// Step 1: Company
|
||||||
if (frame > T.COMPANY_TYPE_START) {
|
if (frame > T.COMPANY_TYPE_START) {
|
||||||
const text = "Mintel Studios";
|
const text = "Mintel Studios";
|
||||||
const progress = interpolate(frame, [T.COMPANY_TYPE_START, T.COMPANY_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
|
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));
|
state.companyName = text.substring(0, Math.round(progress));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: URL
|
// Step 2: URL
|
||||||
if (frame > T.URL_TYPE_START) {
|
if (frame > T.URL_TYPE_START) {
|
||||||
const text = "mintel.me";
|
const text = "mintel.me";
|
||||||
const progress = interpolate(frame, [T.URL_TYPE_START, T.URL_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
|
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));
|
state.existingWebsite = text.substring(0, Math.round(progress));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Selections
|
// Step 3: Selections
|
||||||
if (frame > T.SCOPE_CLICK_1) state.selectedPages = ['Home', 'About'];
|
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_2)
|
||||||
if (frame > T.SCOPE_CLICK_3) state.features = ['blog_news'];
|
state.selectedPages = ["Home", "About", "Services"];
|
||||||
|
if (frame > T.SCOPE_CLICK_3) state.features = ["blog_news"];
|
||||||
|
|
||||||
// Step 4: Design
|
// Step 4: Design
|
||||||
if (frame > T.VIBE_SELECT) state.designVibe = 'tech';
|
if (frame > T.VIBE_SELECT) state.designVibe = "tech";
|
||||||
|
|
||||||
// Step 5: Contact
|
// Step 5: Contact
|
||||||
if (frame > T.NAME_TYPE_START) {
|
if (frame > T.NAME_TYPE_START) {
|
||||||
const text = "Marc Mintel";
|
const text = "Marc Mintel";
|
||||||
const progress = interpolate(frame, [T.NAME_TYPE_START, T.NAME_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
|
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));
|
state.name = text.substring(0, Math.round(progress));
|
||||||
}
|
}
|
||||||
if (frame > T.EMAIL_TYPE_START) {
|
if (frame > T.EMAIL_TYPE_START) {
|
||||||
const text = "marc@mintel.me";
|
const text = "marc@mintel.me";
|
||||||
const progress = interpolate(frame, [T.EMAIL_TYPE_START, T.EMAIL_TYPE_END], [0, text.length], { extrapolateRight: 'clamp' });
|
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));
|
state.email = text.substring(0, Math.round(progress));
|
||||||
}
|
}
|
||||||
if (frame > T.MESSAGE_TYPE_START) {
|
if (frame > T.MESSAGE_TYPE_START) {
|
||||||
const text = "Hi folks! Let's build something cinematic and smooth.";
|
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' });
|
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));
|
state.message = text.substring(0, Math.round(progress));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,16 +161,19 @@ export const ContactFormShowcase: React.FC = () => {
|
|||||||
}, [frame, T]);
|
}, [frame, T]);
|
||||||
|
|
||||||
// ---- CAMERA ANCHORS ----
|
// ---- CAMERA ANCHORS ----
|
||||||
const anchors = useMemo(() => ({
|
const anchors = useMemo(
|
||||||
'overview': { z: 0.85, x: 0, y: 0 },
|
() => ({
|
||||||
'type': { z: 1.15, x: 250, y: 150 },
|
overview: { z: 0.85, x: 0, y: 0 },
|
||||||
'company': { z: 1.3, x: 100, y: 50 },
|
type: { z: 1.15, x: 250, y: 150 },
|
||||||
'presence': { z: 1.3, x: 100, y: -50 },
|
company: { z: 1.3, x: 100, y: 50 },
|
||||||
'scope': { z: 1.1, x: -100, y: 0 },
|
presence: { z: 1.3, x: 100, y: -50 },
|
||||||
'design': { z: 1.15, x: 150, y: 100 },
|
scope: { z: 1.1, x: -100, y: 0 },
|
||||||
'contact': { z: 1.25, x: 0, y: 400 },
|
design: { z: 1.15, x: 150, y: 100 },
|
||||||
'success': { z: 0.9, x: 0, y: 0 },
|
contact: { z: 1.25, x: 0, y: 400 },
|
||||||
}), []);
|
success: { z: 0.9, x: 0, y: 0 },
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const activeAnchor = useMemo(() => {
|
const activeAnchor = useMemo(() => {
|
||||||
if (frame < T.SELECT_WEBSITE) return anchors.overview;
|
if (frame < T.SELECT_WEBSITE) return anchors.overview;
|
||||||
@@ -153,25 +186,19 @@ export const ContactFormShowcase: React.FC = () => {
|
|||||||
return anchors.success;
|
return anchors.success;
|
||||||
}, [frame, anchors, T]);
|
}, [frame, anchors, T]);
|
||||||
|
|
||||||
const camera = useMemo(() => {
|
const _camera = useMemo(() => {
|
||||||
// Continuous organic spring follow
|
// Continuous organic spring follow
|
||||||
const s = spring({
|
const _s = spring({
|
||||||
frame,
|
frame,
|
||||||
fps,
|
fps,
|
||||||
config: { stiffness: 45, damping: 20 },
|
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;
|
return activeAnchor;
|
||||||
}, [frame, activeAnchor, fps]);
|
}, [frame, activeAnchor, fps]);
|
||||||
|
|
||||||
// Simple smooth camera interpolation for the actual movement
|
// Simple smooth camera interpolation for the actual movement
|
||||||
const smoothCamera = useMemo(() => {
|
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;
|
return activeAnchor;
|
||||||
}, [activeAnchor]);
|
}, [activeAnchor]);
|
||||||
|
|
||||||
@@ -236,11 +263,19 @@ export const ContactFormShowcase: React.FC = () => {
|
|||||||
|
|
||||||
const isClicking = useMemo(() => {
|
const isClicking = useMemo(() => {
|
||||||
const clicks = [
|
const clicks = [
|
||||||
T.SELECT_WEBSITE, T.NEXT_0, T.NEXT_1, T.NEXT_2,
|
T.SELECT_WEBSITE,
|
||||||
T.SCOPE_CLICK_1, T.SCOPE_CLICK_2, T.SCOPE_CLICK_3, T.NEXT_3,
|
T.NEXT_0,
|
||||||
T.VIBE_SELECT, T.NEXT_4, T.SUBMIT
|
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);
|
return clicks.some((c) => frame >= c && frame < c + 8);
|
||||||
}, [frame, T]);
|
}, [frame, T]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -258,17 +293,25 @@ export const ContactFormShowcase: React.FC = () => {
|
|||||||
transform: `translate3d(${Math.round(smoothCamera.x)}px, ${Math.round(smoothCamera.y)}px, 0) scale(${smoothCamera.z.toFixed(4)})`,
|
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">
|
<div
|
||||||
|
style={{
|
||||||
|
transform: "scale(0.85) translate3d(0,0,0)",
|
||||||
|
width: "1200px",
|
||||||
|
}}
|
||||||
|
className="focus-layer"
|
||||||
|
>
|
||||||
<ContactForm initialStepIndex={stepIndex} initialState={formState} />
|
<ContactForm initialStepIndex={stepIndex} initialState={formState} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: '50%', left: '50%',
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
transform: `translate3d(${Math.round(mouse.x)}px, ${Math.round(mouse.y)}px, 0)`,
|
transform: `translate3d(${Math.round(mouse.x)}px, ${Math.round(mouse.y)}px, 0)`,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
marginTop: -12, marginLeft: -6,
|
marginTop: -12,
|
||||||
|
marginLeft: -6,
|
||||||
}}
|
}}
|
||||||
className="focus-layer"
|
className="focus-layer"
|
||||||
>
|
>
|
||||||
@@ -285,7 +328,11 @@ export const ContactFormShowcase: React.FC = () => {
|
|||||||
|
|
||||||
<AbsoluteFill
|
<AbsoluteFill
|
||||||
className="bg-white pointer-events-none"
|
className="bg-white pointer-events-none"
|
||||||
style={{ opacity: interpolate(frame, [0, 15], [1, 0], { extrapolateRight: 'clamp' }) }}
|
style={{
|
||||||
|
opacity: interpolate(frame, [0, 15], [1, 0], {
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import React from 'react';
|
/* eslint-disable no-unused-vars */
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
// ULTRA-CRITICAL ANIMATION KILLER
|
// ULTRA-CRITICAL ANIMATION KILLER
|
||||||
// This mock covers all possible Framer Motion V12 entry points
|
// This mock covers all possible Framer Motion V12 entry points
|
||||||
// and forces absolute determinism on both HTML and SVG elements.
|
// and forces absolute determinism on both HTML and SVG elements.
|
||||||
|
|
||||||
const createMotionComponent = (Tag: string) => {
|
const createMotionComponent = (Tag: string) => {
|
||||||
const Component = React.forwardRef(({
|
const Component = React.forwardRef(
|
||||||
children, style, animate, initial, whileHover, whileTap,
|
(
|
||||||
transition, layout, layoutId,
|
{
|
||||||
variants, ...props
|
children,
|
||||||
}: any, ref) => {
|
style,
|
||||||
|
animate,
|
||||||
|
initial,
|
||||||
|
_whileHover,
|
||||||
|
_whileTap,
|
||||||
|
_transition,
|
||||||
|
_layout,
|
||||||
|
_layoutId,
|
||||||
|
_variants,
|
||||||
|
...props
|
||||||
|
}: any,
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
// 1. Resolve State
|
// 1. Resolve State
|
||||||
// If animate is a string (variant), we try to find it in variants,
|
// 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
|
// but since we want to be deterministic, we just ignore variants for now
|
||||||
@@ -20,7 +32,7 @@ const createMotionComponent = (Tag: string) => {
|
|||||||
// Framer motion allows animating SVG attributes like 'r', 'cx' directly.
|
// Framer motion allows animating SVG attributes like 'r', 'cx' directly.
|
||||||
// We must spread 'animate' into the props to snap them.
|
// We must spread 'animate' into the props to snap them.
|
||||||
const resolvedProps = { ...props };
|
const resolvedProps = { ...props };
|
||||||
if (typeof animate === 'object' && !Array.isArray(animate)) {
|
if (typeof animate === "object" && !Array.isArray(animate)) {
|
||||||
Object.assign(resolvedProps, animate);
|
Object.assign(resolvedProps, animate);
|
||||||
} else if (Array.isArray(animate)) {
|
} else if (Array.isArray(animate)) {
|
||||||
// Handle keyframes by taking the first one
|
// Handle keyframes by taking the first one
|
||||||
@@ -30,15 +42,30 @@ const createMotionComponent = (Tag: string) => {
|
|||||||
// 3. Resolve Style
|
// 3. Resolve Style
|
||||||
const combinedStyle = {
|
const combinedStyle = {
|
||||||
...style,
|
...style,
|
||||||
...(typeof initial === 'object' && !Array.isArray(initial) ? initial : {}),
|
...(typeof initial === "object" && !Array.isArray(initial)
|
||||||
...(typeof animate === 'object' && !Array.isArray(animate) ? animate : {})
|
? initial
|
||||||
|
: {}),
|
||||||
|
...(typeof animate === "object" && !Array.isArray(animate)
|
||||||
|
? animate
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Final cleaning of motion-specific props that shouldn't leak to DOM
|
// Final cleaning of motion-specific props that shouldn't leak to DOM
|
||||||
const {
|
const {
|
||||||
viewport, transition: _t, onAnimationStart, onAnimationComplete,
|
_viewport,
|
||||||
onUpdate, onPan, onPanStart, onPanEnd, onPanSessionStart,
|
transition: __t,
|
||||||
onTap, onTapStart, onTapCancel, onHoverStart, onHoverEnd,
|
_onAnimationStart,
|
||||||
|
_onAnimationComplete,
|
||||||
|
_onUpdate,
|
||||||
|
_onPan,
|
||||||
|
_onPanStart,
|
||||||
|
_onPanEnd,
|
||||||
|
_onPanSessionStart,
|
||||||
|
_onTap,
|
||||||
|
_onTapStart,
|
||||||
|
_onTapCancel,
|
||||||
|
_onHoverStart,
|
||||||
|
_onHoverEnd,
|
||||||
...domProps
|
...domProps
|
||||||
} = resolvedProps;
|
} = resolvedProps;
|
||||||
|
|
||||||
@@ -52,34 +79,35 @@ const createMotionComponent = (Tag: string) => {
|
|||||||
{children}
|
{children}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
Component.displayName = `motion.${Tag}`;
|
Component.displayName = `motion.${Tag}`;
|
||||||
return Component;
|
return Component;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const motion: any = {
|
export const motion: any = {
|
||||||
div: createMotionComponent('div'),
|
div: createMotionComponent("div"),
|
||||||
button: createMotionComponent('button'),
|
button: createMotionComponent("button"),
|
||||||
h1: createMotionComponent('h1'),
|
h1: createMotionComponent("h1"),
|
||||||
h2: createMotionComponent('h2'),
|
h2: createMotionComponent("h2"),
|
||||||
h3: createMotionComponent('h3'),
|
h3: createMotionComponent("h3"),
|
||||||
h4: createMotionComponent('h4'),
|
h4: createMotionComponent("h4"),
|
||||||
p: createMotionComponent('p'),
|
p: createMotionComponent("p"),
|
||||||
span: createMotionComponent('span'),
|
span: createMotionComponent("span"),
|
||||||
section: createMotionComponent('section'),
|
section: createMotionComponent("section"),
|
||||||
nav: createMotionComponent('nav'),
|
nav: createMotionComponent("nav"),
|
||||||
svg: createMotionComponent('svg'),
|
svg: createMotionComponent("svg"),
|
||||||
path: createMotionComponent('path'),
|
path: createMotionComponent("path"),
|
||||||
circle: createMotionComponent('circle'),
|
circle: createMotionComponent("circle"),
|
||||||
rect: createMotionComponent('rect'),
|
rect: createMotionComponent("rect"),
|
||||||
line: createMotionComponent('line'),
|
line: createMotionComponent("line"),
|
||||||
polyline: createMotionComponent('polyline'),
|
polyline: createMotionComponent("polyline"),
|
||||||
polygon: createMotionComponent('polygon'),
|
polygon: createMotionComponent("polygon"),
|
||||||
ellipse: createMotionComponent('ellipse'),
|
ellipse: createMotionComponent("ellipse"),
|
||||||
g: createMotionComponent('g'),
|
g: createMotionComponent("g"),
|
||||||
a: createMotionComponent('a'),
|
a: createMotionComponent("a"),
|
||||||
li: createMotionComponent('li'),
|
li: createMotionComponent("li"),
|
||||||
ul: createMotionComponent('ul'),
|
ul: createMotionComponent("ul"),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const m = motion;
|
export const m = motion;
|
||||||
@@ -90,20 +118,24 @@ export const LazyMotion = ({ children }: any) => <>{children}</>;
|
|||||||
|
|
||||||
export const useAnimation = () => ({
|
export const useAnimation = () => ({
|
||||||
start: () => Promise.resolve(),
|
start: () => Promise.resolve(),
|
||||||
set: () => { },
|
set: () => {},
|
||||||
stop: () => { },
|
stop: () => {},
|
||||||
mount: () => { },
|
mount: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useInView = () => true;
|
export const useInView = () => true;
|
||||||
export const useScroll = () => ({
|
export const useScroll = () => ({
|
||||||
scrollYProgress: { get: () => 0, onChange: () => () => { }, getVelocity: () => 0 },
|
scrollYProgress: {
|
||||||
scrollY: { get: () => 0, onChange: () => () => { }, getVelocity: () => 0 }
|
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 useSpring = (value: any) => value;
|
||||||
export const useCycle = (...args: any[]) => [args[0], () => { }];
|
export const useCycle = (...args: any[]) => [args[0], () => {}];
|
||||||
export const useIsPresent = () => true;
|
export const useIsPresent = () => true;
|
||||||
export const useReducedMotion = () => true;
|
export const useReducedMotion = () => true;
|
||||||
export const useAnimationControls = useAnimation;
|
export const useAnimationControls = useAnimation;
|
||||||
|
|||||||
Reference in New Issue
Block a user