Implementierung - SEO - 01.08.2025
This commit is contained in:
@@ -10,11 +10,15 @@ import PageTransition from './components/PageTransition';
|
||||
import { ScrollProvider } from './components/ScrollContext';
|
||||
import DebugOverlay from './components/DebugOverlay';
|
||||
import { logPageView, isCookieCategoryEnabled } from './utils/analytics';
|
||||
import { useScrollTracking } from './hooks/useScrollTracking';
|
||||
import './styles/scrollbar.css';
|
||||
|
||||
// RouteTracker Component für präzises Seiten-Tracking - aber nur mit Zustimmung
|
||||
const RouteTracker = () => {
|
||||
const location = useLocation();
|
||||
|
||||
// Scroll tracking aktivieren
|
||||
useScrollTracking();
|
||||
|
||||
useEffect(() => {
|
||||
// Nur tracken, wenn die Analytics-Cookies akzeptiert wurden
|
||||
|
||||
@@ -1,87 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface FAQItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
category?: string;
|
||||
}
|
||||
import { trackFAQInteraction } from '../services/gtm';
|
||||
|
||||
export function FAQSection() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [openItems, setOpenItems] = useState<number[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const [openItem, setOpenItem] = useState<number | null>(null);
|
||||
|
||||
const faqs: FAQItem[] = i18n.language === 'de' ? [
|
||||
{
|
||||
question: "Wie kann ich Python für Prozessautomatisierung einsetzen?",
|
||||
answer: "Python eignet sich hervorragend für Prozessautomatisierung durch Libraries wie Selenium für Web-Automation, Pandas für Datenverarbeitung und Schedule für zeitgesteuerte Aufgaben. Ich entwickle maßgeschneiderte Automatisierungslösungen für Ihre Geschäftsprozesse.",
|
||||
category: "python"
|
||||
},
|
||||
{
|
||||
question: "Was macht OLLAMA ideal für lokale KI-Entwicklung?",
|
||||
answer: "OLLAMA ermöglicht das Ausführen großer Sprachmodelle lokal, was Datenschutz gewährleistet und API-Kosten reduziert. Ich spezialisiere mich auf die Integration von OLLAMA in Anwendungen für individuelle KI-Lösungen ohne Cloud-Abhängigkeiten.",
|
||||
category: "ai"
|
||||
},
|
||||
{
|
||||
question: "Welche Vorteile bieten Electron Desktop Apps?",
|
||||
answer: "Electron ermöglicht die Entwicklung plattformübergreifender Desktop-Anwendungen mit Web-Technologien. Sie erhalten eine native App für Windows, Mac und Linux aus einer einzigen Codebasis. Ideal für Tools mit komplexer UI und Offline-Funktionalität.",
|
||||
category: "electron"
|
||||
},
|
||||
{
|
||||
question: "Wie lange dauert die Entwicklung einer Web-Anwendung?",
|
||||
answer: "Die Entwicklungsdauer hängt vom Umfang ab. Ein MVP kann in 4-8 Wochen entwickelt werden, während komplexe Enterprise-Anwendungen 3-6 Monate benötigen. Ich arbeite agil und liefere in 2-Wochen-Sprints nutzbare Zwischenergebnisse.",
|
||||
category: "general"
|
||||
},
|
||||
{
|
||||
question: "Können Sie bestehende Python-Skripte in Web-Apps umwandeln?",
|
||||
answer: "Ja, ich kann Ihre Python-Skripte in moderne Web-Anwendungen mit FastAPI oder Django Backend und React Frontend transformieren. Dies ermöglicht Multi-User-Zugriff, bessere UI/UX und zentrale Datenverwaltung.",
|
||||
category: "python"
|
||||
},
|
||||
{
|
||||
question: "Wie integrieren Sie KI in bestehende Geschäftsprozesse?",
|
||||
answer: "Ich analysiere Ihre Workflows und identifiziere Automatisierungspotenziale. Mit OLLAMA oder anderen KI-Modellen entwickle ich Lösungen für Textanalyse, Dokumentenverarbeitung, Kundenservice-Automation oder Datenextraktion - alles datenschutzkonform on-premise.",
|
||||
category: "ai"
|
||||
}
|
||||
] : [
|
||||
{
|
||||
question: "How can I use Python for process automation?",
|
||||
answer: "Python excels at process automation through libraries like Selenium for web automation, Pandas for data processing, and Schedule for timed tasks. I develop custom automation solutions tailored to your business processes.",
|
||||
category: "python"
|
||||
},
|
||||
{
|
||||
question: "What makes OLLAMA ideal for local AI development?",
|
||||
answer: "OLLAMA enables running large language models locally, ensuring data privacy and reducing API costs. I specialize in integrating OLLAMA into applications for custom AI solutions without cloud dependencies.",
|
||||
category: "ai"
|
||||
},
|
||||
{
|
||||
question: "What are the advantages of Electron desktop apps?",
|
||||
answer: "Electron enables cross-platform desktop app development using web technologies. You get a native app for Windows, Mac, and Linux from a single codebase. Ideal for tools requiring complex UI and offline functionality.",
|
||||
category: "electron"
|
||||
},
|
||||
{
|
||||
question: "How long does web application development take?",
|
||||
answer: "Development time depends on scope. An MVP can be developed in 4-8 weeks, while complex enterprise applications require 3-6 months. I work agile and deliver usable results in 2-week sprints.",
|
||||
category: "general"
|
||||
},
|
||||
{
|
||||
question: "Can you convert existing Python scripts into web apps?",
|
||||
answer: "Yes, I can transform your Python scripts into modern web applications with FastAPI or Django backend and React frontend. This enables multi-user access, better UI/UX, and centralized data management.",
|
||||
category: "python"
|
||||
},
|
||||
{
|
||||
question: "How do you integrate AI into existing business processes?",
|
||||
answer: "I analyze your workflows and identify automation potential. Using OLLAMA or other AI models, I develop solutions for text analysis, document processing, customer service automation, or data extraction - all privacy-compliant on-premise.",
|
||||
category: "ai"
|
||||
}
|
||||
];
|
||||
const faqs = t('faq.questions', { returnObjects: true }) as Array<{
|
||||
question: string;
|
||||
answer: string;
|
||||
category?: string;
|
||||
}>;
|
||||
|
||||
const toggleItem = (index: number) => {
|
||||
setOpenItems(prev =>
|
||||
prev.includes(index)
|
||||
? prev.filter(i => i !== index)
|
||||
: [...prev, index]
|
||||
);
|
||||
const isOpening = openItem !== index;
|
||||
const question = faqs[index]?.question || '';
|
||||
|
||||
// Track FAQ interaction
|
||||
trackFAQInteraction(question, isOpening ? 'open' : 'close');
|
||||
|
||||
setOpenItem(prev => prev === index ? null : index);
|
||||
};
|
||||
|
||||
const faqSchema = {
|
||||
@@ -105,7 +44,7 @@ export function FAQSection() {
|
||||
/>
|
||||
|
||||
<h2 className="text-3xl font-bold text-white mb-8 text-center">
|
||||
{t('faq.title', 'Frequently Asked Questions')}
|
||||
{t('faq.title')}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -117,13 +56,13 @@ export function FAQSection() {
|
||||
<button
|
||||
onClick={() => toggleItem(index)}
|
||||
className="w-full px-6 py-4 text-left flex items-center justify-between hover:bg-zinc-800/70 transition-colors"
|
||||
aria-expanded={openItems.includes(index)}
|
||||
aria-expanded={openItem === index}
|
||||
aria-controls={`faq-answer-${index}`}
|
||||
>
|
||||
<h3 className="text-lg font-medium text-white pr-4">
|
||||
{faq.question}
|
||||
</h3>
|
||||
{openItems.includes(index) ? (
|
||||
{openItem === index ? (
|
||||
<ChevronUp className="h-5 w-5 text-zinc-400 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-zinc-400 flex-shrink-0" />
|
||||
@@ -133,7 +72,7 @@ export function FAQSection() {
|
||||
<div
|
||||
id={`faq-answer-${index}`}
|
||||
className={`overflow-hidden transition-all duration-300 ${
|
||||
openItems.includes(index) ? 'max-h-96' : 'max-h-0'
|
||||
openItem === index ? 'max-h-96' : 'max-h-0'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 py-4 text-zinc-300 border-t border-zinc-700">
|
||||
@@ -146,12 +85,12 @@ export function FAQSection() {
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-zinc-400">
|
||||
{t('faq.moreQuestions', 'Have more questions?')}{' '}
|
||||
{t('faq.moreQuestions')}{' '}
|
||||
<a
|
||||
href="/contact"
|
||||
className="text-white hover:text-zinc-300 underline transition-colors"
|
||||
>
|
||||
{t('faq.contactUs', 'Contact us')}
|
||||
{t('faq.contactUs')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function FloatingPaths({ position }: { position: number }) {
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none floating-paths" style={{ isolation: 'isolate', zIndex: 0 }}>
|
||||
<div className="absolute inset-0 pointer-events-none floating-paths">
|
||||
<svg className="w-full h-full text-accent" viewBox="0 0 696 316" fill="none" style={{ transform: 'translateZ(0)' }}>
|
||||
<title>Background Paths</title>
|
||||
{paths.map((path) => (
|
||||
|
||||
@@ -8,7 +8,7 @@ const Footer = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="relative bg-zinc-900 pt-8 pb-4 px-4">
|
||||
<footer className="relative bg-zinc-800/50 backdrop-blur-sm border-t border-zinc-700/30 pt-8 pb-4 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col space-y-8">
|
||||
{/* Contact Section */}
|
||||
@@ -93,7 +93,7 @@ const Footer = () => {
|
||||
</div>
|
||||
|
||||
{/* Legal Section */}
|
||||
<div className="border-t border-zinc-800 pt-4">
|
||||
<div className="border-t border-zinc-700/30 pt-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<p className="text-zinc-400 text-xs text-center">
|
||||
© {currentYear} Damjan Savić. {t('footer.legal.copyright')}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FloatingPaths } from './FloatingPaths';
|
||||
|
||||
const GlobalBackground = () => {
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrollPosition(window.scrollY * 0.001);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Background layer - behind everything */}
|
||||
<div
|
||||
className="fixed inset-0 bg-zinc-900"
|
||||
style={{
|
||||
zIndex: -2,
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient overlay */}
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
background: 'linear-gradient(135deg, #1e1e1e 0%, #2a2a2a 50%, #1e1e1e 100%)',
|
||||
opacity: 0.9
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* FloatingPaths layer */}
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
<FloatingPaths position={scrollPosition} />
|
||||
</div>
|
||||
|
||||
{/* Test: Visible animated lines */}
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
style={{ zIndex: -1 }}
|
||||
>
|
||||
<svg
|
||||
className="w-full h-full"
|
||||
preserveAspectRatio="none"
|
||||
style={{ opacity: 0.3 }}
|
||||
>
|
||||
<line
|
||||
x1="0%"
|
||||
y1="20%"
|
||||
x2="100%"
|
||||
y2="20%"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
opacity="0.2"
|
||||
>
|
||||
<animate
|
||||
attributeName="y1"
|
||||
values="20%;80%;20%"
|
||||
dur="10s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="y2"
|
||||
values="20%;80%;20%"
|
||||
dur="10s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
|
||||
<line
|
||||
x1="0%"
|
||||
y1="50%"
|
||||
x2="100%"
|
||||
y2="50%"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
opacity="0.15"
|
||||
>
|
||||
<animate
|
||||
attributeName="x1"
|
||||
values="0%;100%;0%"
|
||||
dur="15s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
|
||||
<line
|
||||
x1="20%"
|
||||
y1="0%"
|
||||
x2="20%"
|
||||
y2="100%"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
opacity="0.1"
|
||||
>
|
||||
<animate
|
||||
attributeName="x1"
|
||||
values="20%;80%;20%"
|
||||
dur="12s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="x2"
|
||||
values="20%;80%;20%"
|
||||
dur="12s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Grid overlay */}
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '50px 50px',
|
||||
opacity: 0.5
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalBackground;
|
||||
@@ -20,6 +20,7 @@ import { Link, useLocation } from "react-router-dom";
|
||||
import Footer from "./Footer";
|
||||
import { useScrollContext } from "./ScrollContext";
|
||||
import { PerformanceMonitor } from "./PerformanceMonitor";
|
||||
import GlobalBackground from "./GlobalBackground";
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -122,21 +123,25 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
// Render with opacity 0 on initial mount to prevent flicker
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-900">
|
||||
<div className="min-h-screen bg-transparent">
|
||||
<GlobalBackground />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-900 flex flex-col relative">
|
||||
<div className="min-h-screen flex flex-col relative">
|
||||
{/* Global Background */}
|
||||
<GlobalBackground />
|
||||
|
||||
{/* Navigation – oberste Ebene */}
|
||||
<nav
|
||||
className={`
|
||||
fixed w-full z-40 transition-all duration-300
|
||||
bg-zinc-800/50 backdrop-blur-sm border-b border-zinc-700/30
|
||||
${scrolled
|
||||
? "bg-zinc-900/80 backdrop-blur-md shadow-lg shadow-zinc-900/50"
|
||||
? "bg-zinc-800/70 backdrop-blur-md shadow-lg shadow-zinc-900/30"
|
||||
: ""
|
||||
}
|
||||
`}
|
||||
@@ -148,7 +153,7 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center space-x-2 group shrink-0">
|
||||
<motion.img
|
||||
src="/logo.png"
|
||||
src="/logo.svg"
|
||||
alt=""
|
||||
className="h-8 w-auto"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
@@ -206,7 +211,7 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="fixed right-0 top-0 h-full w-80 bg-zinc-900 shadow-xl md:hidden z-50 border-l border-zinc-800"
|
||||
className="fixed right-0 top-0 h-full w-80 bg-zinc-900/95 backdrop-blur-md shadow-xl md:hidden z-50 border-l border-zinc-800"
|
||||
>
|
||||
{/* Sidebar Header mit Close-Button */}
|
||||
<div className="flex items-center justify-between px-4 h-16 border-b border-zinc-800">
|
||||
@@ -258,7 +263,7 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Hauptinhalt */}
|
||||
<main className="flex-1 pt-2 relative z-30">{children}</main>
|
||||
<main className="flex-1 pt-2 relative z-10 bg-transparent">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
|
||||
@@ -28,7 +28,7 @@ const Logo = () => {
|
||||
>
|
||||
{!imageError ? (
|
||||
<img
|
||||
src="/logo.png"
|
||||
src="/logo.svg"
|
||||
alt="Logo"
|
||||
className="w-full h-full object-contain filter brightness-200"
|
||||
onError={() => setImageError(true)}
|
||||
@@ -123,7 +123,7 @@ const PageTransition = ({ children }: PageTransitionProps) => {
|
||||
{isTransitioning && (
|
||||
<motion.div
|
||||
key="transition-overlay"
|
||||
className="fixed inset-0 bg-zinc-900 flex items-center justify-center page-transition-active"
|
||||
className="fixed inset-0 bg-zinc-900/95 backdrop-blur-sm flex items-center justify-center page-transition-active"
|
||||
style={{ zIndex: 9999 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { trackScrollDepth } from '../services/gtm';
|
||||
|
||||
/**
|
||||
* Hook to track scroll depth for GTM
|
||||
*/
|
||||
export const useScrollTracking = () => {
|
||||
const trackedDepths = useRef(new Set<number>());
|
||||
|
||||
useEffect(() => {
|
||||
let scrollTimeout: NodeJS.Timeout;
|
||||
const depths = [25, 50, 75, 90, 100];
|
||||
|
||||
const handleScroll = () => {
|
||||
clearTimeout(scrollTimeout);
|
||||
|
||||
scrollTimeout = setTimeout(() => {
|
||||
const windowHeight = window.innerHeight;
|
||||
const documentHeight = document.documentElement.scrollHeight;
|
||||
const scrollTop = window.scrollY;
|
||||
const scrollPercentage = Math.round(
|
||||
((scrollTop + windowHeight) / documentHeight) * 100
|
||||
);
|
||||
|
||||
// Track each depth milestone only once
|
||||
depths.forEach(depth => {
|
||||
if (scrollPercentage >= depth && !trackedDepths.current.has(depth)) {
|
||||
trackedDepths.current.add(depth);
|
||||
trackScrollDepth(depth);
|
||||
}
|
||||
});
|
||||
}, 100); // Debounce scroll events
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
|
||||
// Check initial scroll position
|
||||
handleScroll();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
clearTimeout(scrollTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset tracked depths when route changes
|
||||
useEffect(() => {
|
||||
trackedDepths.current.clear();
|
||||
}, [window.location.pathname]);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
export const faq = {
|
||||
title: 'Häufig gestellte Fragen',
|
||||
moreQuestions: 'Haben Sie weitere Fragen?',
|
||||
contactUs: 'Kontaktieren Sie uns',
|
||||
questions: [
|
||||
{
|
||||
question: "Wie kann ich Python für Prozessautomatisierung einsetzen?",
|
||||
answer: "Python eignet sich hervorragend für Prozessautomatisierung durch Libraries wie Selenium für Web-Automation, Pandas für Datenverarbeitung und Schedule für zeitgesteuerte Aufgaben. Ich entwickle maßgeschneiderte Automatisierungslösungen für Ihre Geschäftsprozesse.",
|
||||
category: "python"
|
||||
},
|
||||
{
|
||||
question: "Was macht OLLAMA ideal für lokale KI-Entwicklung?",
|
||||
answer: "OLLAMA ermöglicht das Ausführen großer Sprachmodelle lokal, was Datenschutz gewährleistet und API-Kosten reduziert. Ich spezialisiere mich auf die Integration von OLLAMA in Anwendungen für individuelle KI-Lösungen ohne Cloud-Abhängigkeiten.",
|
||||
category: "ai"
|
||||
},
|
||||
{
|
||||
question: "Welche Vorteile bieten Electron Desktop Apps?",
|
||||
answer: "Electron ermöglicht die Entwicklung plattformübergreifender Desktop-Anwendungen mit Web-Technologien. Sie erhalten eine native App für Windows, Mac und Linux aus einer einzigen Codebasis. Ideal für Tools mit komplexer UI und Offline-Funktionalität.",
|
||||
category: "electron"
|
||||
},
|
||||
{
|
||||
question: "Wie lange dauert die Entwicklung einer Web-Anwendung?",
|
||||
answer: "Die Entwicklungsdauer hängt vom Umfang ab. Ein MVP kann in 4-8 Wochen entwickelt werden, während komplexe Enterprise-Anwendungen 3-6 Monate benötigen. Ich arbeite agil und liefere in 2-Wochen-Sprints nutzbare Zwischenergebnisse.",
|
||||
category: "general"
|
||||
},
|
||||
{
|
||||
question: "Können Sie bestehende Python-Skripte in Web-Apps umwandeln?",
|
||||
answer: "Ja, ich kann Ihre Python-Skripte in moderne Web-Anwendungen mit FastAPI oder Django Backend und React Frontend transformieren. Dies ermöglicht Multi-User-Zugriff, bessere UI/UX und zentrale Datenverwaltung.",
|
||||
category: "python"
|
||||
},
|
||||
{
|
||||
question: "Wie integrieren Sie KI in bestehende Geschäftsprozesse?",
|
||||
answer: "Ich analysiere Ihre Workflows und identifiziere Automatisierungspotenziale. Mit OLLAMA oder anderen KI-Modellen entwickle ich Lösungen für Textanalyse, Dokumentenverarbeitung, Kundenservice-Automation oder Datenextraktion - alles datenschutzkonform on-premise.",
|
||||
category: "ai"
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -29,6 +29,9 @@ import { navigation } from './navigation';
|
||||
// Footer translations
|
||||
import footer from './footer';
|
||||
|
||||
// FAQ translations
|
||||
import { faq } from './faq';
|
||||
|
||||
// Zusammenführung aller Übersetzungsobjekte
|
||||
const translations = {
|
||||
// Pages
|
||||
@@ -77,6 +80,7 @@ const translations = {
|
||||
},
|
||||
meta,
|
||||
navigation,
|
||||
faq,
|
||||
} as const;
|
||||
|
||||
// Default-Export: komplettes Übersetzungsobjekt
|
||||
@@ -111,4 +115,5 @@ export {
|
||||
common,
|
||||
meta,
|
||||
navigation,
|
||||
faq,
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
export const faq = {
|
||||
title: 'Frequently Asked Questions',
|
||||
moreQuestions: 'Have more questions?',
|
||||
contactUs: 'Contact us',
|
||||
questions: [
|
||||
{
|
||||
question: "How can I use Python for process automation?",
|
||||
answer: "Python excels at process automation through libraries like Selenium for web automation, Pandas for data processing, and Schedule for timed tasks. I develop custom automation solutions tailored to your business processes.",
|
||||
category: "python"
|
||||
},
|
||||
{
|
||||
question: "What makes OLLAMA ideal for local AI development?",
|
||||
answer: "OLLAMA enables running large language models locally, ensuring data privacy and reducing API costs. I specialize in integrating OLLAMA into applications for custom AI solutions without cloud dependencies.",
|
||||
category: "ai"
|
||||
},
|
||||
{
|
||||
question: "What are the advantages of Electron desktop apps?",
|
||||
answer: "Electron enables cross-platform desktop app development using web technologies. You get a native app for Windows, Mac, and Linux from a single codebase. Ideal for tools requiring complex UI and offline functionality.",
|
||||
category: "electron"
|
||||
},
|
||||
{
|
||||
question: "How long does web application development take?",
|
||||
answer: "Development time depends on scope. An MVP can be developed in 4-8 weeks, while complex enterprise applications require 3-6 months. I work agile and deliver usable results in 2-week sprints.",
|
||||
category: "general"
|
||||
},
|
||||
{
|
||||
question: "Can you convert existing Python scripts into web apps?",
|
||||
answer: "Yes, I can transform your Python scripts into modern web applications with FastAPI or Django backend and React frontend. This enables multi-user access, better UI/UX, and centralized data management.",
|
||||
category: "python"
|
||||
},
|
||||
{
|
||||
question: "How do you integrate AI into existing business processes?",
|
||||
answer: "I analyze your workflows and identify automation potential. Using OLLAMA or other AI models, I develop solutions for text analysis, document processing, customer service automation, or data extraction - all privacy-compliant on-premise.",
|
||||
category: "ai"
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -29,6 +29,9 @@ import { navigation } from './navigation';
|
||||
// Footer translations
|
||||
import footer from './footer';
|
||||
|
||||
// FAQ translations
|
||||
import { faq } from './faq';
|
||||
|
||||
// Merging all translation objects
|
||||
const translations = {
|
||||
// Pages
|
||||
@@ -77,6 +80,7 @@ const translations = {
|
||||
},
|
||||
meta,
|
||||
navigation,
|
||||
faq,
|
||||
} as const;
|
||||
|
||||
// Default export: complete translations object
|
||||
@@ -111,4 +115,5 @@ export {
|
||||
common,
|
||||
meta,
|
||||
navigation,
|
||||
faq,
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
export const faq = {
|
||||
title: 'Često postavljana pitanja',
|
||||
moreQuestions: 'Imate još pitanja?',
|
||||
contactUs: 'Kontaktirajte nas',
|
||||
questions: [
|
||||
{
|
||||
question: "Kako mogu da koristim Python za automatizaciju procesa?",
|
||||
answer: "Python je odličan za automatizaciju procesa kroz biblioteke kao što su Selenium za web automatizaciju, Pandas za obradu podataka i Schedule za vremenski zakazane zadatke. Razvijam prilagođena rešenja za automatizaciju vaših poslovnih procesa.",
|
||||
category: "python"
|
||||
},
|
||||
{
|
||||
question: "Šta čini OLLAMA idealnim za lokalni razvoj veštačke inteligencije?",
|
||||
answer: "OLLAMA omogućava pokretanje velikih jezičkih modela lokalno, što garantuje privatnost podataka i smanjuje troškove API-ja. Specijalizovan sam za integraciju OLLAMA-e u aplikacije za prilagođena AI rešenja bez zavisnosti od oblaka.",
|
||||
category: "ai"
|
||||
},
|
||||
{
|
||||
question: "Koje su prednosti Electron desktop aplikacija?",
|
||||
answer: "Electron omogućava razvoj desktop aplikacija za više platformi koristeći web tehnologije. Dobijate nativnu aplikaciju za Windows, Mac i Linux iz jedne baze koda. Idealno za alate koji zahtevaju kompleksan UI i offline funkcionalnost.",
|
||||
category: "electron"
|
||||
},
|
||||
{
|
||||
question: "Koliko dugo traje razvoj web aplikacije?",
|
||||
answer: "Vreme razvoja zavisi od obima. MVP može biti razvijen za 4-8 nedelja, dok kompleksne enterprise aplikacije zahtevaju 3-6 meseci. Radim agilno i isporučujem upotrebljive rezultate u dvonedeljnim sprintovima.",
|
||||
category: "general"
|
||||
},
|
||||
{
|
||||
question: "Možete li konvertovati postojeće Python skripte u web aplikacije?",
|
||||
answer: "Da, mogu transformisati vaše Python skripte u moderne web aplikacije sa FastAPI ili Django backend-om i React frontend-om. Ovo omogućava multi-korisnički pristup, bolji UI/UX i centralizovano upravljanje podacima.",
|
||||
category: "python"
|
||||
},
|
||||
{
|
||||
question: "Kako integrisati veštačku inteligenciju u postojeće poslovne procese?",
|
||||
answer: "Analiziram vaše radne tokove i identifikujem potencijal za automatizaciju. Koristeći OLLAMA ili druge AI modele, razvijam rešenja za analizu teksta, obradu dokumenata, automatizaciju korisničke podrške ili ekstrakciju podataka - sve u skladu sa privatnošću podataka on-premise.",
|
||||
category: "ai"
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -29,6 +29,9 @@ import { navigation } from './navigation';
|
||||
// Footer prevodi
|
||||
import footer from './footer';
|
||||
|
||||
// FAQ prevodi
|
||||
import { faq } from './faq';
|
||||
|
||||
// Objedinjavanje svih prevoda
|
||||
const translations = {
|
||||
// Stranice
|
||||
@@ -77,6 +80,7 @@ const translations = {
|
||||
},
|
||||
meta,
|
||||
navigation,
|
||||
faq,
|
||||
} as const;
|
||||
|
||||
// Default izvoz: kompletan objekat prevoda
|
||||
@@ -111,4 +115,5 @@ export {
|
||||
common,
|
||||
meta,
|
||||
navigation,
|
||||
faq,
|
||||
};
|
||||
+36
-1
@@ -19,8 +19,17 @@ html.fonts-fallback body {
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
@apply text-foreground antialiased;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
#root {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@@ -78,6 +87,32 @@ html.fonts-fallback body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-right {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
0% {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-right {
|
||||
animation: slide-right 8s linear infinite;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slide-down 8s linear infinite;
|
||||
}
|
||||
|
||||
/* Prevent hover flickering */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { FloatingPaths } from '../../../components/FloatingPaths';
|
||||
import { Terminal, Globe2, Code2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -18,28 +17,7 @@ const Hero = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative min-h-screen bg-zinc-900 text-white overflow-hidden">
|
||||
{/* Floating Paths Background */}
|
||||
<div className="absolute inset-0">
|
||||
<FloatingPaths position={1} />
|
||||
<FloatingPaths position={-1} />
|
||||
</div>
|
||||
|
||||
{/* Concentric Circles Background */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{[1, 2, 3, 4, 5].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute rounded-full border border-zinc-800"
|
||||
style={{
|
||||
width: `${index * 20}%`,
|
||||
height: `${index * 20}%`,
|
||||
opacity: 0.1
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="relative min-h-screen text-white overflow-hidden">
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-4 relative z-10">
|
||||
{/* Title */}
|
||||
|
||||
@@ -37,7 +37,7 @@ const Skills = () => {
|
||||
const allSkills = skillGroups.flatMap(group => group.items);
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-zinc-900">
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
|
||||
@@ -39,7 +39,7 @@ const Workflow = () => {
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative w-full bg-zinc-900 py-24 px-4 sm:px-6 lg:px-8"
|
||||
className="relative w-full py-24 px-4 sm:px-6 lg:px-8"
|
||||
aria-label={t('pages.about.workflow.aria.workflowSection')}
|
||||
>
|
||||
{/* Section Title and Description */}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Calendar, ArrowLeft, Tag, Loader2, Folder } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PageTransition from '../../../components/PageTransition';
|
||||
import SEO from '../../../components/SEO';
|
||||
import { trackBlogPostView } from '../../../services/gtm';
|
||||
|
||||
import { blog as blogDe } from '../../../i18n/locales/de/blog/';
|
||||
import { blog as blogEn } from '../../../i18n/locales/en/blog';
|
||||
@@ -120,6 +121,8 @@ const BlogPost: React.FC = () => {
|
||||
...postData,
|
||||
slug,
|
||||
});
|
||||
// Track blog post view
|
||||
trackBlogPostView(postData.title, postData.category);
|
||||
} else {
|
||||
// Statt eine Exception zu werfen, setzen wir den Fehler direkt.
|
||||
setError(new Error('Post not found'));
|
||||
@@ -168,7 +171,7 @@ const BlogPost: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -183,7 +186,7 @@ const BlogPost: React.FC = () => {
|
||||
|
||||
if (error || !post) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -207,10 +210,43 @@ const BlogPost: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Article Schema for SEO
|
||||
const articleSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": post.title,
|
||||
"description": post.excerpt,
|
||||
"datePublished": post.date,
|
||||
"dateModified": post.date,
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Damjan Savić",
|
||||
"url": "https://damjan-savic.com"
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Person",
|
||||
"name": "Damjan Savić",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https://damjan-savic.com/logo.svg"
|
||||
}
|
||||
},
|
||||
"mainEntityOfPage": {
|
||||
"@type": "WebPage",
|
||||
"@id": `https://damjan-savic.com/blog/${post.slug}`
|
||||
},
|
||||
"articleSection": post.category ? blog.categories[post.category] : undefined,
|
||||
"keywords": post.tags?.join(', ')
|
||||
};
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<SEO title={`${post.title} - Blog`} description={post.excerpt} />
|
||||
<main className="min-h-screen bg-zinc-900">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
|
||||
/>
|
||||
<main className="min-h-screen">
|
||||
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20 lg:py-24">
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="visible">
|
||||
{/* Back Navigation */}
|
||||
|
||||
@@ -72,7 +72,7 @@ const BlogPage: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -87,7 +87,7 @@ const BlogPage: React.FC = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -110,7 +110,7 @@ const BlogPage: React.FC = () => {
|
||||
description={metaDescription}
|
||||
schema={schema}
|
||||
/>
|
||||
<main className="min-h-screen bg-zinc-900">
|
||||
<main className="min-h-screen">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20 lg:py-24">
|
||||
{/* Header Section */}
|
||||
<motion.div
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Label } from '../../../components/Label';
|
||||
import { Button } from '../../../components/Button';
|
||||
import { Alert, AlertDescription } from '../../../components/Alert';
|
||||
import { Loader2, Send, CheckCircle2, Mail } from 'lucide-react';
|
||||
import { trackContactFormSubmit } from '../../../services/gtm';
|
||||
|
||||
interface FormData {
|
||||
name: string;
|
||||
@@ -72,6 +73,13 @@ const ContactForm = () => {
|
||||
setSubmitStatus('idle');
|
||||
|
||||
try {
|
||||
// Track form submission
|
||||
trackContactFormSubmit({
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
subject: 'Contact Form'
|
||||
});
|
||||
|
||||
// Simuliere einen API-Aufruf
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
setSubmitStatus('success');
|
||||
|
||||
@@ -9,7 +9,7 @@ const ContactPage = () => {
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<div className="min-h-screen bg-zinc-900">
|
||||
<div className="min-h-screen">
|
||||
{/* Hero Section */}
|
||||
<div className="py-24 text-center">
|
||||
<motion.h1
|
||||
|
||||
@@ -1,88 +1,119 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
import { MapPin, Briefcase, GraduationCap, Code, Award } from 'lucide-react';
|
||||
|
||||
const About = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const highlights = [
|
||||
{ icon: <MapPin className="w-4 h-4" />, label: "Köln, Deutschland" },
|
||||
{ icon: <Briefcase className="w-4 h-4" />, label: "5+ Jahre Erfahrung" },
|
||||
{ icon: <Code className="w-4 h-4" />, label: "Full-Stack Developer" },
|
||||
{ icon: <Award className="w-4 h-4" />, label: "ERP Spezialist" }
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-zinc-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<section className="py-24 relative">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center"
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'opacity, transform'
|
||||
}}
|
||||
>
|
||||
{/* Image Section */}
|
||||
<div className="relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="rounded-3xl overflow-hidden aspect-square"
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'opacity, transform'
|
||||
}}
|
||||
>
|
||||
{/* Image Section - Simplified */}
|
||||
<motion.div
|
||||
className="relative"
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
{/* Main Image Container */}
|
||||
<div className="relative rounded-3xl overflow-hidden aspect-square bg-zinc-800/30 backdrop-blur-sm">
|
||||
<img
|
||||
src="/portrait.jpg"
|
||||
alt={t('pages.home.about.image.alt')}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = 'data:image/svg+xml,%3Csvg width="400" height="400" xmlns="http://www.w3.org/2000/svg"%3E%3Crect width="400" height="400" fill="%2327272a"/%3E%3Ctext x="50%25" y="50%25" font-family="system-ui" font-size="24" fill="%2371717a" text-anchor="middle" dominant-baseline="middle"%3EDamjan Savić%3C/text%3E%3C/svg%3E';
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="space-y-6">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="text-3xl font-bold text-white"
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'opacity, transform'
|
||||
}}
|
||||
>
|
||||
{t('pages.home.about.title')}
|
||||
</motion.h2>
|
||||
|
||||
{/* Title */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="text-zinc-400 leading-relaxed"
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'opacity, transform'
|
||||
}}
|
||||
>
|
||||
<div className="text-zinc-400 leading-relaxed text-justify">
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-2">
|
||||
{t('pages.home.about.title')}
|
||||
</h2>
|
||||
<div className="h-1 w-20 bg-gradient-to-r from-zinc-600 to-zinc-800 rounded-full"></div>
|
||||
</motion.div>
|
||||
|
||||
{/* Highlight Pills */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="flex flex-wrap gap-3"
|
||||
>
|
||||
{highlights.map((item, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.5 + index * 0.1 }}
|
||||
whileHover={{ scale: 1.05, y: -2 }}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-zinc-800/30 backdrop-blur-sm rounded-full border border-zinc-700/50 hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
<span className="text-zinc-400">{item.icon}</span>
|
||||
<span className="text-zinc-300 text-sm">{item.label}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Main Content */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<p className="text-zinc-300 leading-relaxed text-lg">
|
||||
{t('pages.home.about.content')}
|
||||
</div>
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Call to Action Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="flex flex-wrap gap-4 pt-4"
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="px-6 py-3 bg-zinc-800/50 backdrop-blur-sm hover:bg-zinc-700/50 text-white rounded-xl font-medium transition-colors duration-300 flex items-center gap-2"
|
||||
>
|
||||
<GraduationCap className="w-4 h-4" />
|
||||
Mehr erfahren
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="px-6 py-3 bg-transparent hover:bg-zinc-800/30 text-zinc-300 border border-zinc-700/50 rounded-xl font-medium transition-colors duration-300"
|
||||
>
|
||||
CV herunterladen
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Copyright */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="text-center mt-24"
|
||||
/>
|
||||
|
||||
{/* Background Dots Pattern */}
|
||||
<div className="absolute top-12 right-12 grid grid-cols-7 gap-2 opacity-20">
|
||||
{[...Array(28)].map((_, i) => (
|
||||
<div key={i} className="w-1 h-1 rounded-full bg-zinc-600" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -53,12 +53,15 @@ const Experience = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-12 md:py-24 bg-zinc-900 overflow-hidden">
|
||||
<section className="py-12 md:py-24 overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
ease: [0.25, 0.46, 0.45, 0.94]
|
||||
}}
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'opacity, transform'
|
||||
@@ -77,10 +80,13 @@ const Experience = () => {
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentPage}
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, x: 100, scale: 0.95 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: -100, scale: 0.95 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: [0.43, 0.13, 0.23, 0.96]
|
||||
}}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-6"
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
@@ -90,13 +96,22 @@ const Experience = () => {
|
||||
{getCurrentPageItems().map((exp, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="p-6 md:p-8 rounded-3xl bg-zinc-800/40 hover:bg-zinc-800/60 transition-colors"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
delay: index * 0.15,
|
||||
ease: [0.25, 0.46, 0.45, 0.94]
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.02,
|
||||
y: -5,
|
||||
transition: { duration: 0.3 }
|
||||
}}
|
||||
className="p-6 md:p-8 rounded-3xl bg-zinc-800/40 hover:bg-zinc-800/60 transition-all duration-300 hover:shadow-xl hover:shadow-zinc-900/50"
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'opacity'
|
||||
willChange: 'opacity, transform'
|
||||
}}
|
||||
>
|
||||
<h3 className="text-lg md:text-xl font-semibold text-white mb-1">
|
||||
@@ -110,12 +125,20 @@ const Experience = () => {
|
||||
</p>
|
||||
<ul className="space-y-3 md:space-y-4">
|
||||
{exp.highlights?.map((highlight: string, hIndex: number) => (
|
||||
<li
|
||||
<motion.li
|
||||
key={hIndex}
|
||||
className="text-zinc-400 text-sm leading-relaxed"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: 0.3 + (hIndex * 0.1),
|
||||
ease: "easeOut"
|
||||
}}
|
||||
className="text-zinc-400 text-sm leading-relaxed flex items-start"
|
||||
>
|
||||
• {highlight}
|
||||
</li>
|
||||
<span className="text-white/40 mr-2 mt-0.5">→</span>
|
||||
<span>{highlight}</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
@@ -129,29 +152,40 @@ const Experience = () => {
|
||||
<div className="flex justify-center items-center gap-4 mt-8">
|
||||
<button
|
||||
onClick={() => currentPage > 0 && setCurrentPage(prev => prev - 1)}
|
||||
className="text-zinc-400 hover:text-white transition-colors p-2"
|
||||
className="text-zinc-400 hover:text-white transition-all duration-300 p-2 text-2xl font-light disabled:opacity-30"
|
||||
disabled={currentPage === 0}
|
||||
aria-label={t('pages.home.experience.navigation.prev')}
|
||||
>
|
||||
{t('pages.home.experience.navigation.prev')}
|
||||
‹
|
||||
</button>
|
||||
{[...Array(totalPages)].map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentPage(index)}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
index === currentPage ? 'bg-white' : 'bg-zinc-600 hover:bg-zinc-400'
|
||||
}`}
|
||||
aria-label={t('pages.home.experience.navigation.page', { page: index + 1 })}
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
{[...Array(totalPages)].map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentPage(index)}
|
||||
className={`rounded-full transition-all duration-300 ${
|
||||
index === currentPage
|
||||
? 'bg-white'
|
||||
: 'bg-zinc-600 hover:bg-zinc-400'
|
||||
}`}
|
||||
style={{
|
||||
width: '5px',
|
||||
height: '5px',
|
||||
minWidth: '5px',
|
||||
minHeight: '5px',
|
||||
padding: '0'
|
||||
}}
|
||||
aria-label={t('pages.home.experience.navigation.page', { page: index + 1 })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => currentPage < totalPages - 1 && setCurrentPage(prev => prev + 1)}
|
||||
className="text-zinc-400 hover:text-white transition-colors p-2"
|
||||
className="text-zinc-400 hover:text-white transition-all duration-300 p-2 text-2xl font-light disabled:opacity-30"
|
||||
disabled={currentPage === totalPages - 1}
|
||||
aria-label={t('pages.home.experience.navigation.next')}
|
||||
>
|
||||
{t('pages.home.experience.navigation.next')}
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Github, Linkedin, Mail } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FloatingPaths } from '../../../components/FloatingPaths';
|
||||
|
||||
const Hero = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -14,12 +13,7 @@ const Hero = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="home" className="relative min-h-screen bg-zinc-900 text-white overflow-hidden">
|
||||
{/* Floating Paths Background - nur eine Instanz für bessere Performance */}
|
||||
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 1 }}>
|
||||
<FloatingPaths position={1} />
|
||||
</div>
|
||||
|
||||
<section id="home" className="relative min-h-screen text-white overflow-hidden">
|
||||
{/* Concentric Circles Background - mit GPU-Beschleunigung */}
|
||||
<div className="absolute inset-0 pointer-events-none flex items-center justify-center" style={{ zIndex: 2 }}>
|
||||
{[1, 2, 3].map((index) => (
|
||||
@@ -68,13 +62,13 @@ const Hero = () => {
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-col items-center justify-start min-h-screen px-4 pt-16 relative" style={{ zIndex: 30 }}>
|
||||
<div className="flex flex-col items-center justify-start min-h-screen px-4 pt-20 relative" style={{ zIndex: 30 }}>
|
||||
{/* Profile Image */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-48 h-48 sm:w-64 sm:h-64 mb-6 rounded-full overflow-hidden border-2 border-zinc-800"
|
||||
className="w-56 h-56 sm:w-72 sm:h-72 mb-8 rounded-full overflow-hidden border-2 border-zinc-800"
|
||||
style={{ transform: 'translateZ(0)', backfaceVisibility: 'hidden' }}
|
||||
>
|
||||
<img
|
||||
@@ -91,7 +85,7 @@ const Hero = () => {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="text-white text-sm tracking-wider mb-2"
|
||||
className="text-white text-base sm:text-lg tracking-wider mb-3"
|
||||
>
|
||||
{t('pages.home.hero.title')}
|
||||
</motion.p>
|
||||
@@ -99,7 +93,7 @@ const Hero = () => {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="text-4xl font-bold mb-8 flex items-center"
|
||||
className="text-5xl sm:text-6xl font-bold mb-10 flex items-center"
|
||||
>
|
||||
{t('pages.home.hero.name')}<span className="animate-pulse ml-1">|</span>
|
||||
</motion.h1>
|
||||
@@ -109,35 +103,35 @@ const Hero = () => {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="flex flex-col sm:flex-row gap-3 sm:gap-6 text-white text-sm z-20"
|
||||
className="flex flex-col sm:flex-row gap-4 sm:gap-8 text-white text-base sm:text-lg z-20"
|
||||
>
|
||||
<button
|
||||
onClick={() => scrollToSection('experience')}
|
||||
className="relative w-full sm:w-auto px-6 py-2 rounded-full uppercase cursor-pointer overflow-hidden group"
|
||||
className="relative w-full sm:w-auto px-8 py-3 rounded-full uppercase cursor-pointer overflow-hidden group"
|
||||
>
|
||||
<span className="relative z-10">{t('pages.home.hero.navigation.experience')}</span>
|
||||
<div className="absolute inset-0 bg-zinc-800 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
|
||||
<div className="absolute inset-0 bg-zinc-700/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection('skills')}
|
||||
className="relative w-full sm:w-auto px-6 py-2 rounded-full uppercase cursor-pointer overflow-hidden group"
|
||||
className="relative w-full sm:w-auto px-8 py-3 rounded-full uppercase cursor-pointer overflow-hidden group"
|
||||
>
|
||||
<span className="relative z-10">{t('pages.home.hero.navigation.skills')}</span>
|
||||
<div className="absolute inset-0 bg-zinc-800 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
|
||||
<div className="absolute inset-0 bg-zinc-700/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection('projects')}
|
||||
className="relative w-full sm:w-auto px-6 py-2 rounded-full uppercase cursor-pointer overflow-hidden group"
|
||||
className="relative w-full sm:w-auto px-8 py-3 rounded-full uppercase cursor-pointer overflow-hidden group"
|
||||
>
|
||||
<span className="relative z-10">{t('pages.home.hero.navigation.projects')}</span>
|
||||
<div className="absolute inset-0 bg-zinc-800 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
|
||||
<div className="absolute inset-0 bg-zinc-700/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection('about')}
|
||||
className="relative w-full sm:w-auto px-6 py-2 rounded-full uppercase cursor-pointer overflow-hidden group"
|
||||
className="relative w-full sm:w-auto px-8 py-3 rounded-full uppercase cursor-pointer overflow-hidden group"
|
||||
>
|
||||
<span className="relative z-10">{t('pages.home.hero.navigation.about')}</span>
|
||||
<div className="absolute inset-0 bg-zinc-800 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
|
||||
<div className="absolute inset-0 bg-zinc-700/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ const Projects = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="py-24 bg-zinc-900">
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-center items-center min-h-[300px] gap-3">
|
||||
<Loader2 className="h-8 w-8 text-zinc-400 animate-spin" />
|
||||
@@ -55,7 +55,7 @@ const Projects = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-zinc-900">
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Database, Server, Store, Code2, CodepenIcon, Box } from 'lucide-react';
|
||||
|
||||
interface Skill {
|
||||
@@ -50,7 +50,7 @@ const Skills = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-zinc-900">
|
||||
<section className="py-24 relative">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -67,32 +67,20 @@ const Skills = () => {
|
||||
<motion.div
|
||||
key={skill.name}
|
||||
className="space-y-2"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
onHoverStart={() => setSelectedSkill(skill.name)}
|
||||
onHoverEnd={() => setSelectedSkill(null)}
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'opacity'
|
||||
}}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: skills.indexOf(skill) * 0.1 }}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white text-sm font-medium">
|
||||
{skill.name}
|
||||
</span>
|
||||
{selectedSkill === skill.name && (
|
||||
<span className="text-zinc-400 text-xs">
|
||||
{skill.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-white text-sm font-medium">
|
||||
{skill.name}
|
||||
</span>
|
||||
<span className="text-white text-sm">
|
||||
{skill.level}%
|
||||
</span>
|
||||
{skill.level}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-2 bg-zinc-800 rounded-full overflow-hidden"
|
||||
className="h-2 bg-zinc-800/50 rounded-full overflow-hidden"
|
||||
role="progressbar"
|
||||
aria-valuenow={skill.level}
|
||||
aria-valuemin={0}
|
||||
@@ -100,12 +88,14 @@ const Skills = () => {
|
||||
aria-label={t('pages.home.skills.aria.skillLevel')}
|
||||
>
|
||||
<motion.div
|
||||
className={`h-full rounded-full ${
|
||||
selectedSkill === skill.name ? 'bg-zinc-500' : 'bg-zinc-600'
|
||||
}`}
|
||||
className="h-full rounded-full bg-gradient-to-r from-zinc-600 to-zinc-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${skill.level}%` }}
|
||||
transition={{ duration: 0.5 }}
|
||||
animate={{
|
||||
width: `${skill.level}%`
|
||||
}}
|
||||
transition={{
|
||||
width: { duration: 1, delay: skills.indexOf(skill) * 0.1 }
|
||||
}}
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'width'
|
||||
@@ -121,32 +111,63 @@ const Skills = () => {
|
||||
{skills.map((skill) => (
|
||||
<motion.div
|
||||
key={skill.name}
|
||||
className={`p-6 rounded-xl bg-zinc-800 flex flex-col items-center justify-center gap-3 cursor-pointer transition-all duration-300 hover:bg-zinc-700 ${
|
||||
selectedSkill === skill.name ? 'ring-2 ring-zinc-500' : ''
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
onClick={() =>
|
||||
setSelectedSkill(skill.name === selectedSkill ? null : skill.name)
|
||||
}
|
||||
role="button"
|
||||
aria-pressed={selectedSkill === skill.name}
|
||||
aria-label={t('pages.home.skills.aria.selectSkill')}
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'transform'
|
||||
}}
|
||||
className="relative"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: skills.indexOf(skill) * 0.1 }}
|
||||
>
|
||||
<div className={selectedSkill === skill.name ? 'text-zinc-500' : 'text-white'}>
|
||||
{iconMap[skill.name]}
|
||||
</div>
|
||||
<span className="text-white text-sm text-center">
|
||||
{skill.name}
|
||||
</span>
|
||||
{selectedSkill === skill.name && (
|
||||
<span className="text-zinc-400 text-xs text-center mt-1">
|
||||
{skill.description}
|
||||
</span>
|
||||
)}
|
||||
<motion.div
|
||||
className={`relative p-6 rounded-xl bg-zinc-800/30 backdrop-blur-sm flex flex-col items-center justify-center gap-3 cursor-pointer transition-colors duration-300 hover:bg-zinc-700/50 ${
|
||||
selectedSkill === skill.name ? 'ring-2 ring-zinc-500 z-20' : ''
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onHoverStart={() => setSelectedSkill(skill.name)}
|
||||
onHoverEnd={() => setSelectedSkill(null)}
|
||||
role="button"
|
||||
aria-pressed={selectedSkill === skill.name}
|
||||
aria-label={t('pages.home.skills.aria.selectSkill')}
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'transform',
|
||||
position: 'relative',
|
||||
zIndex: selectedSkill === skill.name ? 20 : 1
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className={selectedSkill === skill.name ? 'text-zinc-500' : 'text-white'}
|
||||
animate={{
|
||||
rotate: selectedSkill === skill.name ? [0, -10, 10, 0] : 0,
|
||||
scale: selectedSkill === skill.name ? 1.1 : 1
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{iconMap[skill.name]}
|
||||
</motion.div>
|
||||
<span className="text-white text-sm text-center">
|
||||
{skill.name}
|
||||
</span>
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedSkill === skill.name && (
|
||||
<motion.div
|
||||
className="absolute left-1/2 top-0 -translate-x-1/2 bg-zinc-800/80 backdrop-blur-sm rounded-lg px-4 py-2 text-zinc-300 text-xs text-center shadow-xl pointer-events-none"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
transform: 'translate(-50%, calc(-100% - 12px))',
|
||||
zIndex: 999
|
||||
}}
|
||||
>
|
||||
{skill.description}
|
||||
<div className="absolute left-1/2 bottom-0 translate-y-1/2 -translate-x-1/2 w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-zinc-800"></div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -58,13 +58,13 @@ const HomePage = () => {
|
||||
'digital marketing automation. Based in Germany, working with clients worldwide.';
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<>
|
||||
<SEO
|
||||
title="Damjan Savić - Digital Solutions Consultant"
|
||||
description={metaDescription}
|
||||
schema={schema}
|
||||
/>
|
||||
<main className="min-h-screen bg-zinc-900">
|
||||
<main className="min-h-screen">
|
||||
{/* Hero Section */}
|
||||
<div id="home">
|
||||
<Hero />
|
||||
@@ -95,7 +95,7 @@ const HomePage = () => {
|
||||
<FAQSection />
|
||||
</div>
|
||||
</main>
|
||||
</PageTransition>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ const TranslatedProjectPage = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -131,7 +131,7 @@ const TranslatedProjectPage = () => {
|
||||
|
||||
if (error || !project) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -158,7 +158,7 @@ const TranslatedProjectPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-900">
|
||||
<main className="min-h-screen">
|
||||
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20 lg:py-24">
|
||||
{/* Back Navigation */}
|
||||
<div className="mb-8">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ExternalLink, Calendar, Tag } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { trackProjectClick } from '../../../services/gtm';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -33,9 +34,14 @@ const PortfolioCard: React.FC<PortfolioCardProps> = ({ project }) => {
|
||||
// Verwende technologies anstelle von tags wenn verfügbar
|
||||
const displayTags = project.technologies || project.tags;
|
||||
|
||||
const handleClick = () => {
|
||||
trackProjectClick(project.title, project.category);
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/portfolio/${project.slug}`}
|
||||
onClick={handleClick}
|
||||
className="group relative block overflow-hidden bg-zinc-800/30 border border-zinc-800
|
||||
rounded-lg hover:bg-zinc-800/50 hover:border-zinc-700
|
||||
transition-all duration-300 focus:outline-none focus:ring-2
|
||||
|
||||
@@ -77,7 +77,7 @@ const PortfolioGrid = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] bg-zinc-900">
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -94,7 +94,7 @@ const PortfolioGrid = () => {
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] bg-zinc-900">
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -118,7 +118,7 @@ const PortfolioGrid = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 bg-zinc-900">
|
||||
<section className="py-12">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
|
||||
@@ -29,7 +29,7 @@ const ProjectLayout: React.FC<ProjectLayoutProps> = ({ children }) => {
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="min-h-screen bg-zinc-900 relative overflow-x-hidden"
|
||||
className="min-h-screen relative overflow-x-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Google Tag Manager Event Service
|
||||
* Zentrale Verwaltung aller GTM Data Layer Events
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer: any[];
|
||||
}
|
||||
}
|
||||
|
||||
// Initialisiere dataLayer falls nicht vorhanden
|
||||
if (typeof window !== 'undefined' && !window.dataLayer) {
|
||||
window.dataLayer = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Push event to GTM dataLayer
|
||||
*/
|
||||
export const pushToDataLayer = (data: Record<string, any>) => {
|
||||
if (typeof window !== 'undefined' && window.dataLayer) {
|
||||
window.dataLayer.push(data);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Track page view
|
||||
*/
|
||||
export const trackPageView = (pagePath: string, pageTitle: string) => {
|
||||
pushToDataLayer({
|
||||
event: 'page_view',
|
||||
page_path: pagePath,
|
||||
page_title: pageTitle,
|
||||
page_location: window.location.href,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track contact form submission
|
||||
*/
|
||||
export const trackContactFormSubmit = (formData: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
subject?: string;
|
||||
}) => {
|
||||
pushToDataLayer({
|
||||
event: 'contact_form_submit',
|
||||
event_category: 'engagement',
|
||||
event_label: 'contact_form',
|
||||
form_name: formData.name,
|
||||
form_subject: formData.subject,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track portfolio project click
|
||||
*/
|
||||
export const trackProjectClick = (projectName: string, projectCategory?: string) => {
|
||||
pushToDataLayer({
|
||||
event: 'project_click',
|
||||
event_category: 'portfolio',
|
||||
event_label: projectName,
|
||||
project_name: projectName,
|
||||
project_category: projectCategory,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track blog post view
|
||||
*/
|
||||
export const trackBlogPostView = (postTitle: string, postCategory?: string) => {
|
||||
pushToDataLayer({
|
||||
event: 'blog_post_view',
|
||||
event_category: 'content',
|
||||
event_label: postTitle,
|
||||
post_title: postTitle,
|
||||
post_category: postCategory,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track social link click
|
||||
*/
|
||||
export const trackSocialClick = (platform: string) => {
|
||||
pushToDataLayer({
|
||||
event: 'social_click',
|
||||
event_category: 'social',
|
||||
event_label: platform,
|
||||
social_platform: platform,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track file download
|
||||
*/
|
||||
export const trackDownload = (fileName: string, fileType: string) => {
|
||||
pushToDataLayer({
|
||||
event: 'file_download',
|
||||
event_category: 'download',
|
||||
event_label: fileName,
|
||||
file_name: fileName,
|
||||
file_type: fileType,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track navigation click
|
||||
*/
|
||||
export const trackNavigation = (navigationItem: string, navigationType: 'header' | 'footer' | 'menu') => {
|
||||
pushToDataLayer({
|
||||
event: 'navigation_click',
|
||||
event_category: 'navigation',
|
||||
event_label: navigationItem,
|
||||
navigation_item: navigationItem,
|
||||
navigation_type: navigationType,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track scroll depth
|
||||
*/
|
||||
export const trackScrollDepth = (depth: number) => {
|
||||
pushToDataLayer({
|
||||
event: 'scroll_depth',
|
||||
event_category: 'engagement',
|
||||
event_label: `${depth}%`,
|
||||
scroll_depth: depth,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track time on page
|
||||
*/
|
||||
export const trackTimeOnPage = (seconds: number, pagePath: string) => {
|
||||
pushToDataLayer({
|
||||
event: 'time_on_page',
|
||||
event_category: 'engagement',
|
||||
event_label: pagePath,
|
||||
time_seconds: seconds,
|
||||
page_path: pagePath,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track CTA button click
|
||||
*/
|
||||
export const trackCTAClick = (ctaText: string, ctaLocation: string) => {
|
||||
pushToDataLayer({
|
||||
event: 'cta_click',
|
||||
event_category: 'conversion',
|
||||
event_label: ctaText,
|
||||
cta_text: ctaText,
|
||||
cta_location: ctaLocation,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track language change
|
||||
*/
|
||||
export const trackLanguageChange = (oldLang: string, newLang: string) => {
|
||||
pushToDataLayer({
|
||||
event: 'language_change',
|
||||
event_category: 'preference',
|
||||
event_label: `${oldLang}_to_${newLang}`,
|
||||
old_language: oldLang,
|
||||
new_language: newLang,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track FAQ interaction
|
||||
*/
|
||||
export const trackFAQInteraction = (question: string, action: 'open' | 'close') => {
|
||||
pushToDataLayer({
|
||||
event: 'faq_interaction',
|
||||
event_category: 'engagement',
|
||||
event_label: question,
|
||||
faq_question: question,
|
||||
faq_action: action,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Track error
|
||||
*/
|
||||
export const trackError = (errorMessage: string, errorType: string) => {
|
||||
pushToDataLayer({
|
||||
event: 'error',
|
||||
event_category: 'error',
|
||||
event_label: errorType,
|
||||
error_message: errorMessage,
|
||||
error_type: errorType,
|
||||
});
|
||||
};
|
||||
@@ -3,7 +3,6 @@
|
||||
/* 1. Stabilize page transitions */
|
||||
.page-transition-active {
|
||||
z-index: 9999 !important;
|
||||
background-color: rgb(24, 24, 27) !important; /* zinc-900 */
|
||||
}
|
||||
|
||||
/* 2. Prevent floating paths from interfering */
|
||||
|
||||
Reference in New Issue
Block a user