diff --git a/resources/js/pages/marketing/Blog.tsx b/resources/js/pages/marketing/Blog.tsx
index 3c1b104..ebdde0a 100644
--- a/resources/js/pages/marketing/Blog.tsx
+++ b/resources/js/pages/marketing/Blog.tsx
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
+import { motion, useReducedMotion } from 'framer-motion';
interface PostSummary {
id: number;
@@ -56,12 +57,26 @@ const Blog: React.FC = ({ posts }) => {
);
const { t, i18n } = useTranslation('marketing');
const locale = i18n.language || 'de';
+ const shouldReduceMotion = useReducedMotion();
const articles = posts?.data ?? [];
const isLandingLayout = posts.current_page === 1;
const featuredPost = isLandingLayout ? articles[0] : null;
const gridPosts = isLandingLayout ? articles.slice(1, 4) : [];
const listPosts = isLandingLayout ? [] : articles;
const dateLocale = locale === 'en' ? 'en-US' : 'de-DE';
+ const viewportOnce = { once: true, amount: 0.25 };
+ const revealUp = {
+ hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 18 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
+ },
+ };
+ const stagger = {
+ hidden: {},
+ visible: { transition: { staggerChildren: 0.12 } },
+ };
const buildArticleHref = React.useCallback(
(slug?: string | null) => {
@@ -194,8 +209,20 @@ const Blog: React.FC = ({ posts }) => {
return (
-
-
+
+
{t('blog.posts_title')}
@@ -213,9 +240,15 @@ const Blog: React.FC = ({ posts }) => {
{t('blog.read_more')}
-
-
-
+
+
+
{featuredPost.featured_image && (
= ({ posts }) => {
)}
-
-
+
+
{gridPosts.length > 0 && (
-
+
{gridPosts.map((post) => (
-
- {post.featured_image && (
-
-
-
- )}
-
-
-
- {post.title || 'Untitled'}
-
-
-
-
- {renderPostMeta(post)}
-
-
- {t('blog.read_more')}
-
-
-
-
+
+
+ {post.featured_image && (
+
+
+
+ )}
+
+
+
+ {post.title || 'Untitled'}
+
+
+
+
+ {renderPostMeta(post)}
+
+
+ {t('blog.read_more')}
+
+
+
+
+
))}
-
+
)}
);
};
const renderListView = () => (
-
+
{listPosts.map((post) => (
-
-
- {post.featured_image && (
-
- )}
-
-
-
- {post.title || 'Untitled'}
-
-
+
+
+ {post.featured_image && (
+
- {renderPostMeta(post)}
-
-
- {t('blog.read_more')}
-
-
-
-
-
-
+ )}
+
+
+
+ {post.title || 'Untitled'}
+
+
+ {renderPostMeta(post)}
+
+
+ {t('blog.read_more')}
+
+
+
+
+
+
+
))}
{listPosts.length === 0 && (
-
{t('blog.empty')}
+
+ {t('blog.empty')}
+
)}
-
+
);
return (
@@ -312,29 +363,46 @@ const Blog: React.FC = ({ posts }) => {
-
-
- Fotospiel Blog
-
-
{t('blog.hero_title')}
-
{t('blog.hero_description')}
-
+
+
+
+ Fotospiel Blog
+
+
+
+ {t('blog.hero_title')}
+
+
+ {t('blog.hero_description')}
+
+
{t('home.cta_explore')}
{t('blog.hero_cta')}
-
-
+
+
-
+
-
+
{t('blog.posts_title')}
-
+
{isLandingLayout ? renderLandingGrid() : renderListView()}
diff --git a/resources/js/pages/marketing/BlogShow.tsx b/resources/js/pages/marketing/BlogShow.tsx
index bb6b65e..1d7d5ff 100644
--- a/resources/js/pages/marketing/BlogShow.tsx
+++ b/resources/js/pages/marketing/BlogShow.tsx
@@ -7,6 +7,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
+import { motion, useReducedMotion } from 'framer-motion';
interface AdjacentPost {
slug: string;
@@ -58,6 +59,20 @@ const BlogShow: React.FC
= ({ post }) => {
const [copied, setCopied] = React.useState(false);
const locale = i18n.language || 'de';
const dateLocale = locale === 'en' ? 'en-US' : 'de-DE';
+ const shouldReduceMotion = useReducedMotion();
+ const viewportOnce = { once: true, amount: 0.25 };
+ const revealUp = {
+ hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 18 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
+ },
+ };
+ const stagger = {
+ hidden: {},
+ visible: { transition: { staggerChildren: 0.12 } },
+ };
const formattedDate = React.useMemo(() => {
try {
@@ -121,9 +136,9 @@ const BlogShow: React.FC = ({ post }) => {
-
-
-
+
+
+
{t('breadcrumb_home')}
@@ -138,51 +153,59 @@ const BlogShow: React.FC = ({ post }) => {
{t('back_to_blog')}
-
+
-
-
-
-
-
- Fotospiel Stories
-
-
{post.title}
-
- {t('by_author')} {post.author?.name || t('team')}
- •
- {t('published_on')} {formattedDate}
-
-
-
-
- {post.featured_image ? (
-
-
-
- ) : (
-
+
+
+
+
+
+
Fotospiel Stories
+
+
{post.title}
+
+ {t('by_author')} {post.author?.name || t('team')}
+ •
+ {t('published_on')} {formattedDate}
- )}
+
+
+
+ {post.featured_image ? (
+
+
+
+ ) : (
+
+ Fotospiel Stories
+
+ )}
+
-
-
-
-
+
+
+
+
-
-
-
+
+
-
-
-
-
- {t('summary_title')}
-
-
-
-
-
-
-
-
- {t('toc_title')}
-
- {post.headings && post.headings.length > 0 ? (
-
- ) : (
- {t('toc_empty')}
- )}
-
-
-
-
-
-
- {t('sidebar_author_title')}
-
- {post.author?.name || t('team')}
- {t('sidebar_author_description')}
-
-
-
-
-
-
+
+
+
+
- {t('share_title')}
+ {t('summary_title')}
- {t('share_hint')}
-
-
-
- {copied ? t('share_copied') : t('share_copy')}
-
- {canUseNativeShare && (
-
navigator.share?.({ title: post.title, url: shareUrl })}
- >
- {t('share_native')}
-
+
+
+
+
+
+
+
+
+
+ {t('toc_title')}
+
+ {post.headings && post.headings.length > 0 ? (
+
+ ) : (
+ {t('toc_empty')}
)}
-
-
-
- {shareLinks.map((link) => (
-
-
- {link.label}
-
+
+
+
+
+
+
+
+
+ {t('sidebar_author_title')}
+
+ {post.author?.name || t('team')}
+ {t('sidebar_author_description')}
+
+
+
+
+
+
+
+
+
+ {t('share_title')}
+
+
{t('share_hint')}
+
+
+
+ {copied ? t('share_copied') : t('share_copy')}
- ))}
-
-
-
-
-
+ {canUseNativeShare && (
+ navigator.share?.({ title: post.title, url: shareUrl })}
+ >
+ {t('share_native')}
+
+ )}
+
+
+
+
+
+
+
+
{(post.previous_post || post.next_post) && (
-
-
+
+
{post.previous_post && (
-
-
-
- {t('previous_post')}
-
- {post.previous_post.title}
-
-
-
- {t('read_story')}
-
-
-
-
+
+
+
+
+ {t('previous_post')}
+
+ {post.previous_post.title}
+
+
+
+ {t('read_story')}
+
+
+
+
+
)}
{post.next_post && (
-
-
-
- {t('next_post')}
-
- {post.next_post.title}
-
-
-
- {t('read_story')}
-
-
-
-
+
+
+
+
+ {t('next_post')}
+
+ {post.next_post.title}
+
+
+
+ {t('read_story')}
+
+
+
+
+
)}
-
+
)}
diff --git a/resources/js/pages/marketing/Demo.tsx b/resources/js/pages/marketing/Demo.tsx
index 6589c5c..d333b49 100644
--- a/resources/js/pages/marketing/Demo.tsx
+++ b/resources/js/pages/marketing/Demo.tsx
@@ -10,6 +10,7 @@ import { Head, Link } from '@inertiajs/react';
import { CheckCircle2, Sparkles } from 'lucide-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
+import { motion, useReducedMotion } from 'framer-motion';
type DemoFeature = { title: string; description: string };
@@ -21,6 +22,7 @@ const DemoPage: React.FC
= ({ demoToken }) => {
const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
const locale = useLocale();
+ const shouldReduceMotion = useReducedMotion();
const embedUrl = demoToken ? `/e/${demoToken}` : '/e/demo?demo=1';
const [isDemoOpen, setIsDemoOpen] = React.useState(false);
const demoOpenLabel = t('labels.demoOpenOverlay', locale === 'en' ? 'Open demo overlay' : 'Demo im Overlay öffnen');
@@ -38,6 +40,25 @@ const DemoPage: React.FC = ({ demoToken }) => {
const handleOpenDemo = (): void => {
setIsDemoOpen(true);
};
+ const frameVariants = shouldReduceMotion
+ ? { rest: {}, hover: {} }
+ : {
+ rest: { y: 0, scale: 1, rotateX: 0, rotateY: 0 },
+ hover: {
+ y: -6,
+ scale: 1.01,
+ rotateX: -2,
+ rotateY: 3,
+ transition: { type: 'spring', stiffness: 180, damping: 20 },
+ },
+ };
+ const shineVariants = shouldReduceMotion
+ ? { rest: {}, hover: {} }
+ : {
+ rest: { x: '-120%', opacity: 0 },
+ hover: { x: '120%', opacity: 0.8, transition: { duration: 0.8, ease: 'easeOut' } },
+ };
+ const featureHover = shouldReduceMotion ? {} : { y: -4, scale: 1.01 };
return (
@@ -74,28 +95,46 @@ const DemoPage: React.FC = ({ demoToken }) => {
{embedUrl ? (
<>
-
-
-
- {isDemoOpen ? (
-
-
Live Demo
-
Demo läuft im Overlay
-
Schließe das Overlay, um hier weiterzumachen.
-
- ) : (
-
- )}
-
+
+
+
+
+
+ {isDemoOpen ? (
+
+
Live Demo
+
Demo läuft im Overlay
+
Schließe das Overlay, um hier weiterzumachen.
+
+ ) : (
+
+ )}
+
+
{demo.iframeNote}
@@ -143,17 +182,19 @@ const DemoPage: React.FC
= ({ demoToken }) => {
{demoFeatures.map((feature) => (
-
-
-
-
- {feature.title}
-
-
-
- {feature.description}
-
-
+
+
+
+
+
+ {feature.title}
+
+
+
+ {feature.description}
+
+
+
))}
diff --git a/resources/js/pages/marketing/Home.tsx b/resources/js/pages/marketing/Home.tsx
index 6e934ff..eeff141 100644
--- a/resources/js/pages/marketing/Home.tsx
+++ b/resources/js/pages/marketing/Home.tsx
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { ArrowRight, Camera, QrCode, ShieldCheck, Sparkles, Smartphone, Loader2, CheckCircle2 } from 'lucide-react';
import { usePage } from '@inertiajs/react';
+import { motion, useReducedMotion } from 'framer-motion';
interface Package {
id: number;
@@ -36,6 +37,7 @@ const Home: React.FC
= ({ packages }) => {
trackClick: trackHeroCtaClick,
} = useCtaExperiment('home_hero_cta');
const { flash } = usePage<{ flash?: { success?: string } }>().props;
+ const shouldReduceMotion = useReducedMotion();
const { data, setData, post, processing, errors, reset } = useForm({
name: '',
email: '',
@@ -43,6 +45,52 @@ const Home: React.FC = ({ packages }) => {
nickname: '',
});
+ const viewportOnce = { once: true, amount: 0.25 };
+ const revealUp = {
+ hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 18 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
+ },
+ };
+ const revealFadeIn = {
+ hidden: { opacity: 0 },
+ visible: { opacity: 1, transition: { duration: 0.5, ease: 'easeOut' } },
+ };
+ const revealFade = {
+ hidden: { opacity: 0 },
+ visible: { opacity: 1, transition: { duration: 0.5, ease: 'easeOut' } },
+ };
+ const stagger = {
+ hidden: {},
+ visible: { transition: { staggerChildren: 0.12 } },
+ };
+ const heroCardVariants = shouldReduceMotion
+ ? { rest: {}, hover: {} }
+ : {
+ rest: { y: 0, scale: 1, rotateX: 0, rotateY: 0 },
+ hover: {
+ y: -8,
+ scale: 1.01,
+ rotateX: -2,
+ rotateY: 3,
+ transition: { type: 'spring', stiffness: 180, damping: 20 },
+ },
+ };
+ const heroGlowVariants = shouldReduceMotion
+ ? { rest: {}, hover: {} }
+ : {
+ rest: { opacity: 0.4, scale: 1 },
+ hover: { opacity: 0.7, scale: 1.03, transition: { duration: 0.35, ease: 'easeOut' } },
+ };
+ const heroShineVariants = shouldReduceMotion
+ ? { rest: {}, hover: {} }
+ : {
+ rest: { x: '-120%', opacity: 0 },
+ hover: { x: '120%', opacity: 0.8, transition: { duration: 0.8, ease: 'easeOut' } },
+ };
+
const heroBulletsRaw = t('home.hero_bullets', { returnObjects: true });
const heroBullets = Array.isArray(heroBulletsRaw) ? (heroBulletsRaw as string[]) : [];
@@ -109,34 +157,53 @@ const Home: React.FC = ({ packages }) => {
-
+
-
- {t('home.hero_tagline')}
-
-
- {t('home.hero_title')}
-
-
- {t('home.hero_description')}
-
+
+
+ {t('home.hero_tagline')}
+
+
+
+
+ {t('home.hero_title')}
+
+
+
+
+ {t('home.hero_description')}
+
+
{heroBullets.length > 0 && (
-
- {heroBullets.map((item, index) => {
- const Icon = heroBulletIcons[index % heroBulletIcons.length] ?? Sparkles;
- return (
-
-
-
-
- {item}
-
- );
- })}
-
+
+
+ {heroBullets.map((item, index) => {
+ const Icon = heroBulletIcons[index % heroBulletIcons.length] ?? Sparkles;
+ return (
+
+
+
+
+ {item}
+
+ );
+ })}
+
+
)}
-
+
= ({ packages }) => {
>
{heroTertiaryLabel}
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
@@ -216,20 +312,25 @@ const Home: React.FC = ({ packages }) => {
{howSteps.map(({ icon: Icon, title, description }, index) => (
-
-
-
-
-
- {title}
-
- {description}
-
-
-
+
+
+
+
+
+ {title}
+
+ {description}
+
+
+
+
))}
@@ -238,36 +339,52 @@ const Home: React.FC
= ({ packages }) => {
-
+
{t('home.demo_title')}
-
+
{t('home.demo_description')}
-
- {t('home.demo_hint')}
-
-
- trackEvent({
- category: 'marketing_home',
- action: 'demo_section_cta',
- })
- }
- className="flex items-center gap-2"
+
+
+ {t('home.demo_hint')}
+
+
+
- {t('home.demo_cta')}
-
-
-
-
-
-
+
+ trackEvent({
+ category: 'marketing_home',
+ action: 'demo_section_cta',
+ })
+ }
+ className="flex items-center gap-2"
+ >
+
{t('home.demo_cta')}
+
+
+
+
+
+
+
@@ -286,7 +403,7 @@ const Home: React.FC
= ({ packages }) => {
-
+
@@ -298,17 +415,22 @@ const Home: React.FC = ({ packages }) => {
{features.map((feature, index) => (
-
-
- {feature.title}
-
- {feature.description}
-
-
-
+
+
+ {feature.title}
+
+ {feature.description}
+
+
+
+
))}
@@ -317,63 +439,67 @@ const Home: React.FC = ({ packages }) => {
-
-
-
- {t('home.occasions_title')}
-
- {t('home.occasions_description')}
-
-
- {occasionLinks.map(({ key, href }) => (
-
- trackEvent({
- category: 'marketing_home',
- action: 'occasion_tile_click',
- name: key,
- })
- }
- className="group flex items-center gap-3 rounded-xl border border-rose-100/60 bg-white/80 px-4 py-3 text-sm font-semibold text-rose-600 shadow-sm shadow-rose-100/50 transition hover:bg-rose-50 hover:text-rose-700 dark:border-rose-500/30 dark:bg-gray-900/70 dark:text-rose-200 dark:hover:bg-rose-500/10"
- >
-
- {t(`home.occasions.${key}`)}
-
- ))}
-
-
+
+
+
+
+ {t('home.occasions_title')}
+
+ {t('home.occasions_description')}
+
+
+ {occasionLinks.map(({ key, href }) => (
+
+ trackEvent({
+ category: 'marketing_home',
+ action: 'occasion_tile_click',
+ name: key,
+ })
+ }
+ className="group flex items-center gap-3 rounded-xl border border-rose-100/60 bg-white/80 px-4 py-3 text-sm font-semibold text-rose-600 shadow-sm shadow-rose-100/50 transition hover:bg-rose-50 hover:text-rose-700 dark:border-rose-500/30 dark:bg-gray-900/70 dark:text-rose-200 dark:hover:bg-rose-500/10"
+ >
+
+ {t(`home.occasions.${key}`)}
+
+ ))}
+
+
+
-
-
- {t('home.blog_teaser_title')}
-
- {t('home.blog_teaser_description')}
-
-
-
-
-
- trackEvent({
- category: 'marketing_home',
- action: 'blog_teaser_cta',
- })
- }
- className="flex items-center gap-2"
+
+
+
+ {t('home.blog_teaser_title')}
+
+ {t('home.blog_teaser_description')}
+
+
+
+
- {t('home.blog_teaser_cta')}
-
-
-
-
-
+
+ trackEvent({
+ category: 'marketing_home',
+ action: 'blog_teaser_cta',
+ })
+ }
+ className="flex items-center gap-2"
+ >
+ {t('home.blog_teaser_cta')}
+
+
+
+
+
+
@@ -408,40 +534,45 @@ const Home: React.FC = ({ packages }) => {
{packages.slice(0, 2).map((pkg) => (
-
-
- {pkg.name}
-
- {pkg.description}
-
-
-
-
- {pkg.price} {t('currency.euro')}
-
-
-
- trackEvent({
- category: 'marketing_home',
- action: 'package_teaser_cta',
- name: pkg.name,
- value: pkg.price,
- })
- }
+
+
+ {pkg.name}
+
+ {pkg.description}
+
+
+
+
+ {pkg.price} {t('currency.euro')}
+
+
- {t('home.view_details')}
-
-
-
-
+
+ trackEvent({
+ category: 'marketing_home',
+ action: 'package_teaser_cta',
+ name: pkg.name,
+ value: pkg.price,
+ })
+ }
+ >
+ {t('home.view_details')}
+
+
+
+
+
))}
diff --git a/resources/js/pages/marketing/HowItWorks.tsx b/resources/js/pages/marketing/HowItWorks.tsx
index c4f091b..56e24d7 100644
--- a/resources/js/pages/marketing/HowItWorks.tsx
+++ b/resources/js/pages/marketing/HowItWorks.tsx
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { CheckCircle2, Images, Sparkles, Users } from 'lucide-react';
+import { motion, useReducedMotion } from 'framer-motion';
type HeroStat = { value: string; label: string };
type ExperienceStep = { title: string; description: string };
@@ -42,6 +43,21 @@ const HowItWorks: React.FC = () => {
const { t, ready } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
const locale = useLocale();
+ const shouldReduceMotion = useReducedMotion();
+
+ const viewportOnce = { once: true, amount: 0.25 };
+ const revealUp = {
+ hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 18 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
+ },
+ };
+ const stagger = {
+ hidden: {},
+ visible: { transition: { staggerChildren: 0.12 } },
+ };
if (!ready) {
return (
@@ -139,17 +155,19 @@ const HowItWorks: React.FC = () => {
-
-
- Fotospiel Flow
-
-
+
+
+
+ Fotospiel Flow
+
+
+
{hero.title}
-
-
+
+
{hero.subtitle}
-
-
+
+
{hero.primaryCta}
@@ -170,23 +188,31 @@ const HowItWorks: React.FC = () => {
{t('packages.gift_cta', 'Paket verschenken')}
-
-
+
+
{heroStats.map((stat) => (
-
-
-
- {stat.value}
-
-
-
-
- {stat.label}
-
-
-
+
+
+
+
+ {stat.value}
+
+
+
+
+ {stat.label}
+
+
+
+
))}
@@ -205,10 +231,14 @@ const HowItWorks: React.FC = () => {
-
+
+
+
-
+
+
+
@@ -226,19 +256,27 @@ const HowItWorks: React.FC = () => {
{pillars.map((pillar) => (
-
-
-
-
- {pillar.title}
-
-
-
-
- {pillar.description}
-
-
-
+
+
+
+
+
+ {pillar.title}
+
+
+
+
+ {pillar.description}
+
+
+
+
))}
@@ -312,48 +350,50 @@ const HowItWorks: React.FC = () => {
{useCases.tabs.map((tab) => (
-
-
-
-
- {tab.label}
-
-
- {tab.goal}
-
-
-
- {iconByUseCase[tab.value] ?? }
-
-
-
-
-
- {t('how_it_works_page.labels.recommendations', 'Empfehlungen')}
-
-
- {tab.recommendations.map((item) => (
-
-
- {item}
-
- ))}
-
-
-
-
- {t('how_it_works_page.labels.challenge_ideas', 'Ideen für Challenges')}
-
-
- {tab.ideas.map((idea) => (
-
- {idea}
-
- ))}
+
+
+
+
+
+ {tab.label}
+
+
+ {tab.goal}
+
-
-
-
+
+ {iconByUseCase[tab.value] ?? }
+
+
+
+
+
+ {t('how_it_works_page.labels.recommendations', 'Empfehlungen')}
+
+
+ {tab.recommendations.map((item) => (
+
+
+ {item}
+
+ ))}
+
+
+
+
+ {t('how_it_works_page.labels.challenge_ideas', 'Ideen für Challenges')}
+
+
+ {tab.ideas.map((idea) => (
+
+ {idea}
+
+ ))}
+
+
+
+
+
))}
@@ -362,29 +402,31 @@ const HowItWorks: React.FC = () => {
-
-
- {checklist.title}
-
- {t('how_it_works_page.labels.prep_hint', 'Alles, was du vor dem Event abhaken solltest.')}
-
-
-
-
- {checklist.items.map((item) => (
-
-
- {item}
-
- ))}
-
-
-
- {checklist.cta}
-
-
-
-
+
+
+
+ {checklist.title}
+
+ {t('how_it_works_page.labels.prep_hint', 'Alles, was du vor dem Event abhaken solltest.')}
+
+
+
+
+ {checklist.items.map((item) => (
+
+
+ {item}
+
+ ))}
+
+
+
+ {checklist.cta}
+
+
+
+
+
diff --git a/resources/js/pages/marketing/Kontakt.tsx b/resources/js/pages/marketing/Kontakt.tsx
index 62e88a9..e81cb24 100644
--- a/resources/js/pages/marketing/Kontakt.tsx
+++ b/resources/js/pages/marketing/Kontakt.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import MarketingLayout from '@/layouts/mainWebsite';
import { Loader2, CheckCircle2 } from 'lucide-react';
+import { motion, useReducedMotion } from 'framer-motion';
const Kontakt: React.FC = () => {
const { data, setData, post, processing, errors, reset } = useForm({
@@ -16,6 +17,7 @@ const Kontakt: React.FC = () => {
const { flash } = usePage<{ flash?: { success?: string } }>().props;
const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
+ const shouldReduceMotion = useReducedMotion();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -34,7 +36,12 @@ const Kontakt: React.FC = () => {
-
+
{t('kontakt.title')}
{t('kontakt.description')}
{flash?.success && (
@@ -120,7 +127,7 @@ const Kontakt: React.FC = () => {
{t('kontakt.back_home')}
-
+
);
diff --git a/resources/js/pages/marketing/Occasions.tsx b/resources/js/pages/marketing/Occasions.tsx
index be17753..6603701 100644
--- a/resources/js/pages/marketing/Occasions.tsx
+++ b/resources/js/pages/marketing/Occasions.tsx
@@ -7,6 +7,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
+import { motion, useReducedMotion } from 'framer-motion';
interface OccasionsProps {
type: string;
@@ -17,6 +18,20 @@ const Occasions: React.FC
= ({ type }) => {
const { localizedPath } = useLocalizedRoutes();
const locale = i18n.language || 'de';
const isEn = locale.toLowerCase().startsWith('en');
+ const shouldReduceMotion = useReducedMotion();
+ const viewportOnce = { once: true, amount: 0.25 };
+ const revealUp = {
+ hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 18 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
+ },
+ };
+ const stagger = {
+ hidden: {},
+ visible: { transition: { staggerChildren: 0.12 } },
+ };
const occasionsContent = {
hochzeit: {
@@ -123,9 +138,9 @@ const Occasions: React.FC = ({ type }) => {
-
-
-
+
+
+
{t('nav.home')}
@@ -138,10 +153,11 @@ const Occasions: React.FC = ({ type }) => {
{t('nav.discover_packages')}
-
+
-
-
+
+
+
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
{t('occasions.weddings.benefits_title')}
@@ -254,9 +271,9 @@ const Occasions: React.FC = ({ type }) => {
5. After the wedding, go through the gallery together and mark favorites for your album.
-
+
-
+
@@ -309,51 +326,55 @@ const Occasions: React.FC = ({ type }) => {
-
-
+
+
-
+
+
+
-
-
-
-
- What couples say
-
-
- “We ended up with more than 800 guest photos – from little mishaps to the most emotional moments. Our
- photographer loved how well the Fotospiel App complemented her work.”
-
-
-
+
+
+
+
+
+ What couples say
+
+
+ “We ended up with more than 800 guest photos – from little mishaps to the most emotional moments. Our
+ photographer loved how well the Fotospiel App complemented her work.”
+
+
+
-
-
-
- Next step
-
-
- Try the Fotospiel App with a test event or set up your wedding gallery right away. You can start small and
- upgrade later at any time.
-
-
-
- {t('home.cta_demo')}
-
-
- {t('occasions.cta')}
-
-
-
-
-
-
-
+
+
+
+ Next step
+
+
+ Try the Fotospiel App with a test event or set up your wedding gallery right away. You can start small and
+ upgrade later at any time.
+
+
+
+ {t('home.cta_demo')}
+
+
+ {t('occasions.cta')}
+
+
+
+
+
+
+
+
);
}
@@ -363,9 +384,9 @@ const Occasions: React.FC = ({ type }) => {
-
-
-
+
+
+
{t('nav.home')}
@@ -378,10 +399,11 @@ const Occasions: React.FC = ({ type }) => {
{t('nav.discover_packages')}
-
+
-
-
+
+
+
-
-
-
+
+
+
+
-
-
-
+
+
+
{t('occasions.weddings.benefits_title')}
@@ -549,12 +572,15 @@ const Occasions: React.FC = ({ type }) => {
-
+
-
+
+
+
-
-
+
+
+
Was Paare berichten
@@ -566,7 +592,7 @@ const Occasions: React.FC = ({ type }) => {
-
+
Nächster Schritt
@@ -590,8 +616,9 @@ const Occasions: React.FC = ({ type }) => {
-
-
+
+
+
);
@@ -604,9 +631,9 @@ const Occasions: React.FC
= ({ type }) => {
-
-
-
+
+
+
{t('nav.home')}
@@ -619,10 +646,11 @@ const Occasions: React.FC = ({ type }) => {
{t('nav.discover_packages')}
-
+
-
-
+
+
+
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
How to turn your party into a photo challenge
@@ -789,29 +818,32 @@ const Occasions: React.FC = ({ type }) => {
-
-
-
+
+
+
+
+
+
-
-
- What hosts say
-
+
+
+ What hosts say
+
“The kids loved the challenges – and we ended up with all photos in one place instead of scrolling through
hundreds of chat messages.”
-
-
+
+
-
-
- Next step
+
+
+ Next step
Create your birthday event in the Fotospiel App now and test the flow with a few friends – your QR code will be
@@ -833,8 +865,9 @@ const Occasions: React.FC = ({ type }) => {
-
-
+
+
+
);
}
@@ -844,9 +877,9 @@ const Occasions: React.FC
= ({ type }) => {
-
-
-
+
+
+
{t('nav.home')}
@@ -859,10 +892,11 @@ const Occasions: React.FC = ({ type }) => {
{t('nav.discover_packages')}
-
+
-
-
+
+
+
-
-
-
+
+
+
+
-
-
-
+
+
+
So wird eure Party zur Foto-Challenge
@@ -1030,12 +1065,15 @@ const Occasions: React.FC = ({ type }) => {
-
+
-
+
+
+
-
-
+
+
+
Was Gastgeber:innen berichten
@@ -1047,7 +1085,7 @@ const Occasions: React.FC = ({ type }) => {
-
+
Nächster Schritt
@@ -1071,8 +1109,9 @@ const Occasions: React.FC = ({ type }) => {
-
-
+
+
+
);
@@ -1085,9 +1124,9 @@ const Occasions: React.FC
= ({ type }) => {
-
-
-
+
+
+
{t('nav.home')}
@@ -1100,10 +1139,11 @@ const Occasions: React.FC = ({ type }) => {
{t('nav.discover_packages')}
-
+
-
-
+
+
+
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
Why the Fotospiel App fits your company
@@ -1271,12 +1312,15 @@ const Occasions: React.FC = ({ type }) => {
-
+
-
+
+
+
-
-
+
+
+
What companies say
@@ -1288,7 +1332,7 @@ const Occasions: React.FC = ({ type }) => {
-
+
Next step
@@ -1312,8 +1356,9 @@ const Occasions: React.FC = ({ type }) => {
-
-
+
+
+
);
@@ -1324,9 +1369,9 @@ const Occasions: React.FC
= ({ type }) => {
-
-
-
+
+
+
{t('nav.home')}
@@ -1339,10 +1384,11 @@ const Occasions: React.FC = ({ type }) => {
{t('nav.discover_packages')}
-
+
-
-
+
+
+
-
-
-
+
+
+
+
-
-
-
+
+
+
Warum die Fotospiel App fürs Unternehmen passt
@@ -1511,12 +1558,15 @@ const Occasions: React.FC = ({ type }) => {
-
+
-
+
+
+
-
-
+
+
+
Was Unternehmen berichten
@@ -1528,7 +1578,7 @@ const Occasions: React.FC = ({ type }) => {
-
+
Nächster Schritt
@@ -1552,8 +1602,9 @@ const Occasions: React.FC = ({ type }) => {
-
-
+
+
+
);
@@ -1567,9 +1618,9 @@ const Occasions: React.FC
= ({ type }) => {
-
-
-
+
+
+
{t('nav.home')}
@@ -1582,10 +1633,11 @@ const Occasions: React.FC = ({ type }) => {
{t('nav.discover_packages')}
-
+
-
-
+
+
+
= ({ type }) => {
-
-
-
+
+
+
+
-
-
-
+
+
+
@@ -1646,8 +1699,8 @@ const Occasions: React.FC = ({ type }) => {
-
-
+
+
);
@@ -1658,9 +1711,9 @@ const Occasions: React.FC = ({ type }) => {
-
-
-
+
+
+
{t('nav.home')}
@@ -1673,10 +1726,11 @@ const Occasions: React.FC = ({ type }) => {
{t('nav.discover_packages')}
-
+
-
-
+
+
+
= ({ type }) => {
-
-
-
+
+
+
+
-
-
-
+
+
+
@@ -1737,8 +1792,8 @@ const Occasions: React.FC = ({ type }) => {
-
-
+
+
);
diff --git a/resources/js/pages/marketing/Packages.tsx b/resources/js/pages/marketing/Packages.tsx
index 4327bd8..f4ce438 100644
--- a/resources/js/pages/marketing/Packages.tsx
+++ b/resources/js/pages/marketing/Packages.tsx
@@ -17,6 +17,7 @@ import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useLocale } from '@/hooks/useLocale';
import { ArrowRight, Check, Star } from 'lucide-react';
import toast from 'react-hot-toast';
+import { motion, useReducedMotion } from 'framer-motion';
interface Package {
id: number;
@@ -325,11 +326,26 @@ const Packages: React.FC = ({ endcustomerPackages, resellerPackag
const { t } = useTranslation('marketing');
const { t: tCommon } = useTranslation('common');
const { flash } = usePage<{ flash?: { error?: string } }>().props;
+ const shouldReduceMotion = useReducedMotion();
const {
variant: packagesHeroVariant,
trackClick: trackPackagesHeroClick,
} = useCtaExperiment('packages_hero_cta');
+ const viewportOnce = { once: true, amount: 0.25 };
+ const revealUp = {
+ hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 18 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
+ },
+ };
+ const stagger = {
+ hidden: {},
+ visible: { transition: { staggerChildren: 0.12 } },
+ };
+
useEffect(() => {
if (flash?.error) {
toast.error(flash.error);
@@ -960,8 +976,13 @@ const PackageDetailGrid: React.FC = ({
return (
-
-
+
+
{t('packages.for_endcustomers')} · {t('packages.for_resellers')}
@@ -971,8 +992,8 @@ const PackageDetailGrid: React.FC = ({
{t('packages.hero_description')}
-
-
+
+
= ({
{t('packages.gift_cta', 'Paket verschenken')}
-
-
+
+
{t('packages.hero_secondary')}
-
-
+
+
@@ -1045,14 +1066,16 @@ const PackageDetailGrid: React.FC = ({
>
{orderedEndcustomerPackages.map((pkg) => (
-
handleCardClick(selected, 'endcustomer')}
- className="h-full"
- compact
- />
+
+ handleCardClick(selected, 'endcustomer')}
+ className="h-full"
+ compact
+ />
+
))}
@@ -1060,18 +1083,27 @@ const PackageDetailGrid: React.FC = ({
{orderedEndcustomerPackages.map((pkg) => (
-
handleCardClick(selected, 'endcustomer')}
- className="h-full"
- compact
- />
+ variants={revealUp}
+ initial="hidden"
+ whileInView="visible"
+ viewport={viewportOnce}
+ >
+ handleCardClick(selected, 'endcustomer')}
+ className="h-full"
+ compact
+ />
+
))}
-
+
+
+
@@ -1086,14 +1118,16 @@ const PackageDetailGrid: React.FC = ({
>
{orderedResellerPackages.map((pkg) => (
-
handleCardClick(selected, 'reseller')}
- className="h-full"
- compact
- />
+
+ handleCardClick(selected, 'reseller')}
+ className="h-full"
+ compact
+ />
+
))}
@@ -1101,18 +1135,27 @@ const PackageDetailGrid: React.FC = ({
{orderedResellerPackages.map((pkg) => (
-
handleCardClick(selected, 'reseller')}
- className="h-full"
- compact
- />
+ variants={revealUp}
+ initial="hidden"
+ whileInView="visible"
+ viewport={viewportOnce}
+ >
+ handleCardClick(selected, 'reseller')}
+ className="h-full"
+ compact
+ />
+
))}
-
+
+
+
@@ -1131,14 +1174,16 @@ const PackageDetailGrid: React.FC = ({
{ title: t('packages.faq_reseller'), body: t('packages.faq_reseller_desc') },
{ title: t('packages.faq_payment'), body: t('packages.faq_payment_desc') },
].map((item) => (
-
-
- {item.title}
-
-
- {item.body}
-
-
+
+
+
+ {item.title}
+
+
+ {item.body}
+
+
+
))}
diff --git a/resources/js/pages/marketing/__tests__/Blog.render.test.tsx b/resources/js/pages/marketing/__tests__/Blog.render.test.tsx
new file mode 100644
index 0000000..2ee1a1e
--- /dev/null
+++ b/resources/js/pages/marketing/__tests__/Blog.render.test.tsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+class IntersectionObserverMock {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+Object.defineProperty(globalThis, 'IntersectionObserver', {
+ writable: true,
+ value: IntersectionObserverMock,
+});
+
+beforeEach(() => {
+ window.matchMedia = vi.fn().mockImplementation(() => ({
+ matches: false,
+ media: '',
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+});
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string | { defaultValue?: unknown }) => {
+ if (typeof fallback === 'string') {
+ return fallback;
+ }
+ if (fallback && typeof fallback === 'object' && 'defaultValue' in fallback) {
+ return fallback.defaultValue;
+ }
+ return key;
+ },
+ i18n: { language: 'de' },
+ }),
+}));
+
+vi.mock('@inertiajs/react', () => ({
+ Head: () => null,
+ Link: ({ children, href }: { children: React.ReactNode; href: string }) => {children} ,
+ usePage: () => ({ props: { supportedLocales: ['de', 'en'] } }),
+}));
+
+vi.mock('@/layouts/mainWebsite', () => ({
+ default: ({ children, title }: { children: React.ReactNode; title: string }) => (
+
+
{title}
+ {children}
+
+ ),
+}));
+
+vi.mock('@/hooks/useLocalizedRoutes', () => ({
+ useLocalizedRoutes: () => ({
+ localizedPath: (path: string) => path,
+ }),
+}));
+
+import Blog from '../Blog';
+
+describe('Blog', () => {
+ it('renders the hero title', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('blog.hero_title')).toBeInTheDocument();
+ });
+});
diff --git a/resources/js/pages/marketing/__tests__/BlogShow.render.test.tsx b/resources/js/pages/marketing/__tests__/BlogShow.render.test.tsx
new file mode 100644
index 0000000..c290b26
--- /dev/null
+++ b/resources/js/pages/marketing/__tests__/BlogShow.render.test.tsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+class IntersectionObserverMock {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+Object.defineProperty(globalThis, 'IntersectionObserver', {
+ writable: true,
+ value: IntersectionObserverMock,
+});
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string | { defaultValue?: unknown }) => {
+ if (typeof fallback === 'string') {
+ return fallback;
+ }
+ if (fallback && typeof fallback === 'object' && 'defaultValue' in fallback) {
+ return fallback.defaultValue;
+ }
+ return key;
+ },
+ i18n: { language: 'de' },
+ }),
+}));
+
+vi.mock('@inertiajs/react', () => ({
+ Head: () => null,
+ Link: ({ children, href }: { children: React.ReactNode; href: string }) => {children} ,
+}));
+
+vi.mock('@/layouts/mainWebsite', () => ({
+ default: ({ children, title }: { children: React.ReactNode; title: string }) => (
+
+
{title}
+ {children}
+
+ ),
+}));
+
+vi.mock('@/hooks/useLocalizedRoutes', () => ({
+ useLocalizedRoutes: () => ({
+ localizedPath: (path: string) => path,
+ }),
+}));
+
+import BlogShow from '../BlogShow';
+
+describe('BlogShow', () => {
+ it('renders the article title', () => {
+ render(
+ Excerpt ',
+ content: 'Content',
+ content_html: 'Content
',
+ headings: [{ text: 'Intro', slug: 'intro', level: 2 }],
+ featured_image: undefined,
+ published_at: '2024-01-01',
+ author: { name: 'Team' },
+ slug: 'hello-world',
+ url: '/blog/hello-world',
+ }}
+ />
+ );
+
+ expect(screen.getAllByText('Hello World').length).toBeGreaterThan(0);
+ });
+});
diff --git a/resources/js/pages/marketing/__tests__/Demo.render.test.tsx b/resources/js/pages/marketing/__tests__/Demo.render.test.tsx
new file mode 100644
index 0000000..58c636e
--- /dev/null
+++ b/resources/js/pages/marketing/__tests__/Demo.render.test.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string | { defaultValue?: unknown }) => {
+ if (key === 'demo_page') {
+ return {
+ title: 'Demo Page',
+ subtitle: 'Subtitle',
+ primaryCta: 'Primary',
+ secondaryCta: 'Secondary',
+ iframeNote: 'Note',
+ openFull: 'Open',
+ features: [],
+ };
+ }
+ if (typeof fallback === 'string') {
+ return fallback;
+ }
+ if (fallback && typeof fallback === 'object' && 'defaultValue' in fallback) {
+ return fallback.defaultValue;
+ }
+ return key;
+ },
+ }),
+}));
+
+vi.mock('@inertiajs/react', () => ({
+ Head: () => null,
+ Link: ({ children, href }: { children: React.ReactNode; href: string }) => {children} ,
+}));
+
+vi.mock('@/layouts/mainWebsite', () => ({
+ default: ({ children, title }: { children: React.ReactNode; title: string }) => (
+
+
{title}
+ {children}
+
+ ),
+}));
+
+vi.mock('@/hooks/useLocalizedRoutes', () => ({
+ useLocalizedRoutes: () => ({
+ localizedPath: (path: string) => path,
+ }),
+}));
+
+vi.mock('@/hooks/useLocale', () => ({
+ useLocale: () => 'de',
+}));
+
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({ children }: { children: React.ReactNode }) => {children}
,
+ DialogContent: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+import DemoPage from '../Demo';
+
+describe('DemoPage', () => {
+ it('renders the demo headline', () => {
+ render( );
+
+ expect(screen.getAllByText('Demo Page').length).toBeGreaterThan(0);
+ });
+});
diff --git a/resources/js/pages/marketing/__tests__/Home.render.test.tsx b/resources/js/pages/marketing/__tests__/Home.render.test.tsx
new file mode 100644
index 0000000..6987cec
--- /dev/null
+++ b/resources/js/pages/marketing/__tests__/Home.render.test.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+class IntersectionObserverMock {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+Object.defineProperty(globalThis, 'IntersectionObserver', {
+ writable: true,
+ value: IntersectionObserverMock,
+});
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string | { defaultValue?: unknown }) => {
+ if (typeof fallback === 'string') {
+ return fallback;
+ }
+ if (fallback && typeof fallback === 'object' && 'defaultValue' in fallback) {
+ return fallback.defaultValue;
+ }
+ return key;
+ },
+ }),
+}));
+
+vi.mock('@inertiajs/react', () => ({
+ Head: () => null,
+ Link: ({ children, href }: { children: React.ReactNode; href: string }) => {children} ,
+ useForm: () => ({
+ data: { name: '', email: '', message: '', nickname: '' },
+ setData: vi.fn(),
+ post: vi.fn(),
+ processing: false,
+ errors: {},
+ reset: vi.fn(),
+ }),
+ usePage: () => ({ props: { flash: {} } }),
+}));
+
+vi.mock('@/layouts/mainWebsite', () => ({
+ default: ({ children, title }: { children: React.ReactNode; title: string }) => (
+
+
{title}
+ {children}
+
+ ),
+}));
+
+vi.mock('@/hooks/useLocalizedRoutes', () => ({
+ useLocalizedRoutes: () => ({
+ localizedPath: (path: string) => path,
+ }),
+}));
+
+vi.mock('@/hooks/useAnalytics', () => ({
+ useAnalytics: () => ({ trackEvent: vi.fn() }),
+}));
+
+vi.mock('@/hooks/useCtaExperiment', () => ({
+ useCtaExperiment: () => ({ variant: 'default', trackClick: vi.fn() }),
+}));
+
+import Home from '../Home';
+
+describe('Home', () => {
+ it('renders the hero headline', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('home.hero_title')).toBeInTheDocument();
+ });
+});
diff --git a/resources/js/pages/marketing/__tests__/HowItWorks.render.test.tsx b/resources/js/pages/marketing/__tests__/HowItWorks.render.test.tsx
new file mode 100644
index 0000000..bb49945
--- /dev/null
+++ b/resources/js/pages/marketing/__tests__/HowItWorks.render.test.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+class IntersectionObserverMock {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+Object.defineProperty(globalThis, 'IntersectionObserver', {
+ writable: true,
+ value: IntersectionObserverMock,
+});
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string | { defaultValue?: unknown }) => {
+ if (typeof fallback === 'string') {
+ return fallback;
+ }
+ if (fallback && typeof fallback === 'object' && 'defaultValue' in fallback) {
+ return fallback.defaultValue;
+ }
+ return key;
+ },
+ ready: true,
+ }),
+}));
+
+vi.mock('@inertiajs/react', () => ({
+ Head: () => null,
+ Link: ({ children, href }: { children: React.ReactNode; href: string }) => {children} ,
+}));
+
+vi.mock('@/layouts/mainWebsite', () => ({
+ default: ({ children, title }: { children: React.ReactNode; title: string }) => (
+
+
{title}
+ {children}
+
+ ),
+}));
+
+vi.mock('@/hooks/useLocalizedRoutes', () => ({
+ useLocalizedRoutes: () => ({
+ localizedPath: (path: string) => path,
+ }),
+}));
+
+vi.mock('@/hooks/useLocale', () => ({
+ useLocale: () => 'de',
+}));
+
+import HowItWorks from '../HowItWorks';
+
+describe('HowItWorks', () => {
+ it('renders the default hero title', () => {
+ render( );
+
+ expect(screen.getAllByText('So funktioniert die Fotospiel App').length).toBeGreaterThan(0);
+ });
+});
diff --git a/resources/js/pages/marketing/__tests__/Kontakt.render.test.tsx b/resources/js/pages/marketing/__tests__/Kontakt.render.test.tsx
new file mode 100644
index 0000000..a83df30
--- /dev/null
+++ b/resources/js/pages/marketing/__tests__/Kontakt.render.test.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+beforeEach(() => {
+ window.matchMedia = vi.fn().mockImplementation(() => ({
+ matches: false,
+ media: '',
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+
+ if (!window.scrollTo) {
+ window.scrollTo = vi.fn();
+ }
+});
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string | { defaultValue?: unknown }) => {
+ if (typeof fallback === 'string') {
+ return fallback;
+ }
+ if (fallback && typeof fallback === 'object' && 'defaultValue' in fallback) {
+ return fallback.defaultValue;
+ }
+ return key;
+ },
+ }),
+}));
+
+vi.mock('@inertiajs/react', () => ({
+ Head: () => null,
+ Link: ({ children, href }: { children: React.ReactNode; href: string }) => {children} ,
+ useForm: () => ({
+ data: { name: '', email: '', message: '', nickname: '' },
+ setData: vi.fn(),
+ post: vi.fn(),
+ processing: false,
+ errors: {},
+ reset: vi.fn(),
+ }),
+ usePage: () => ({ props: { flash: {} } }),
+}));
+
+vi.mock('@/layouts/mainWebsite', () => ({
+ default: ({ children, title }: { children: React.ReactNode; title: string }) => (
+
+
{title}
+ {children}
+
+ ),
+}));
+
+vi.mock('@/hooks/useLocalizedRoutes', () => ({
+ useLocalizedRoutes: () => ({
+ localizedPath: (path: string) => path,
+ }),
+}));
+
+import Kontakt from '../Kontakt';
+
+describe('Kontakt', () => {
+ it('renders the page title', () => {
+ render( );
+
+ expect(screen.getAllByText('kontakt.title').length).toBeGreaterThan(0);
+ });
+});
diff --git a/resources/js/pages/marketing/__tests__/Occasions.render.test.tsx b/resources/js/pages/marketing/__tests__/Occasions.render.test.tsx
new file mode 100644
index 0000000..ed6e0e3
--- /dev/null
+++ b/resources/js/pages/marketing/__tests__/Occasions.render.test.tsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+class IntersectionObserverMock {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+Object.defineProperty(globalThis, 'IntersectionObserver', {
+ writable: true,
+ value: IntersectionObserverMock,
+});
+
+beforeEach(() => {
+ window.matchMedia = vi.fn().mockImplementation(() => ({
+ matches: false,
+ media: '',
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+});
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string | { defaultValue?: unknown }) => {
+ if (typeof fallback === 'string') {
+ return fallback;
+ }
+ if (fallback && typeof fallback === 'object' && 'defaultValue' in fallback) {
+ return fallback.defaultValue;
+ }
+ return key;
+ },
+ i18n: { language: 'de' },
+ }),
+}));
+
+vi.mock('@inertiajs/react', () => ({
+ Head: () => null,
+ Link: ({ children, href }: { children: React.ReactNode; href: string }) => {children} ,
+}));
+
+vi.mock('@/layouts/mainWebsite', () => ({
+ default: ({ children, title }: { children: React.ReactNode; title: string }) => (
+
+
{title}
+ {children}
+
+ ),
+}));
+
+vi.mock('@/hooks/useLocalizedRoutes', () => ({
+ useLocalizedRoutes: () => ({
+ localizedPath: (path: string) => path,
+ }),
+}));
+
+import Occasions from '../Occasions';
+
+describe('Occasions', () => {
+ it('renders the wedding hero content', () => {
+ render( );
+
+ expect(screen.getAllByText('occasions.weddings.title').length).toBeGreaterThan(0);
+ });
+});
diff --git a/resources/js/pages/marketing/__tests__/Packages.render.test.tsx b/resources/js/pages/marketing/__tests__/Packages.render.test.tsx
new file mode 100644
index 0000000..1546f81
--- /dev/null
+++ b/resources/js/pages/marketing/__tests__/Packages.render.test.tsx
@@ -0,0 +1,140 @@
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+class IntersectionObserverMock {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+Object.defineProperty(globalThis, 'IntersectionObserver', {
+ writable: true,
+ value: IntersectionObserverMock,
+});
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string | { defaultValue?: unknown }) => {
+ if (typeof fallback === 'string') {
+ return fallback;
+ }
+ if (fallback && typeof fallback === 'object' && 'defaultValue' in fallback) {
+ return fallback.defaultValue;
+ }
+ return key;
+ },
+ }),
+}));
+
+vi.mock('@inertiajs/react', () => ({
+ Link: ({ children, href }: { children: React.ReactNode; href: string }) => {children} ,
+ usePage: () => ({ props: { flash: {} } }),
+}));
+
+vi.mock('@/layouts/mainWebsite', () => ({
+ default: ({ children, title }: { children: React.ReactNode; title: string }) => (
+
+
{title}
+ {children}
+
+ ),
+}));
+
+vi.mock('@/hooks/useAnalytics', () => ({
+ useAnalytics: () => ({ trackEvent: vi.fn() }),
+}));
+
+vi.mock('@/hooks/useCtaExperiment', () => ({
+ useCtaExperiment: () => ({ variant: 'default', trackClick: vi.fn() }),
+}));
+
+vi.mock('@/hooks/useLocalizedRoutes', () => ({
+ useLocalizedRoutes: () => ({
+ localizedPath: (path: string) => path,
+ }),
+}));
+
+vi.mock('@/hooks/useLocale', () => ({
+ useLocale: () => 'de',
+}));
+
+vi.mock('react-hot-toast', () => ({
+ default: {
+ error: vi.fn(),
+ },
+}));
+
+import Packages from '../Packages';
+
+describe('Packages', () => {
+ beforeEach(() => {
+ window.matchMedia = vi.fn().mockImplementation(() => ({
+ matches: false,
+ media: '',
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+
+ if (!HTMLElement.prototype.scrollTo) {
+ HTMLElement.prototype.scrollTo = vi.fn();
+ }
+ });
+
+ it('renders the hero headline', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('packages.hero_title')).toBeInTheDocument();
+ });
+});