Add marketing motion reveals to blog and occasions

This commit is contained in:
Codex Agent
2026-01-21 15:22:39 +01:00
parent 5eb0941512
commit a01a7ec399
16 changed files with 1869 additions and 781 deletions

View File

@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { motion, useReducedMotion } from 'framer-motion';
interface PostSummary { interface PostSummary {
id: number; id: number;
@@ -56,12 +57,26 @@ const Blog: React.FC<Props> = ({ posts }) => {
); );
const { t, i18n } = useTranslation('marketing'); const { t, i18n } = useTranslation('marketing');
const locale = i18n.language || 'de'; const locale = i18n.language || 'de';
const shouldReduceMotion = useReducedMotion();
const articles = posts?.data ?? []; const articles = posts?.data ?? [];
const isLandingLayout = posts.current_page === 1; const isLandingLayout = posts.current_page === 1;
const featuredPost = isLandingLayout ? articles[0] : null; const featuredPost = isLandingLayout ? articles[0] : null;
const gridPosts = isLandingLayout ? articles.slice(1, 4) : []; const gridPosts = isLandingLayout ? articles.slice(1, 4) : [];
const listPosts = isLandingLayout ? [] : articles; const listPosts = isLandingLayout ? [] : articles;
const dateLocale = locale === 'en' ? 'en-US' : 'de-DE'; 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( const buildArticleHref = React.useCallback(
(slug?: string | null) => { (slug?: string | null) => {
@@ -194,8 +209,20 @@ const Blog: React.FC<Props> = ({ posts }) => {
return ( return (
<div className="space-y-16"> <div className="space-y-16">
<div className="grid gap-8 lg:grid-cols-12 lg:items-center"> <motion.div
<div className="order-2 space-y-6 rounded-3xl border border-gray-200 bg-white p-8 shadow-xl dark:border-gray-800 dark:bg-gray-950 lg:order-1 lg:col-span-6"> className="grid gap-8 lg:grid-cols-12 lg:items-center"
variants={stagger}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
<motion.div
className="order-2 space-y-6 rounded-3xl border border-gray-200 bg-white p-8 shadow-xl dark:border-gray-800 dark:bg-gray-950 lg:order-1 lg:col-span-6"
variants={revealUp}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
<Badge variant="outline" className="w-fit border-pink-200 text-pink-600 dark:border-pink-500 dark:text-pink-300"> <Badge variant="outline" className="w-fit border-pink-200 text-pink-600 dark:border-pink-500 dark:text-pink-300">
{t('blog.posts_title')} {t('blog.posts_title')}
</Badge> </Badge>
@@ -213,9 +240,15 @@ const Blog: React.FC<Props> = ({ posts }) => {
{t('blog.read_more')} {t('blog.read_more')}
</Link> </Link>
</Button> </Button>
</div> </motion.div>
<div className="order-1 lg:order-2 lg:col-span-6"> <motion.div
<div className="relative overflow-hidden rounded-3xl border border-gray-200 bg-gradient-to-br from-pink-200 via-white to-amber-100 shadow-2xl dark:border-gray-800 dark:from-pink-900/40 dark:via-gray-900 dark:to-gray-950"> className="order-1 lg:order-2 lg:col-span-6"
variants={revealUp}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
<div className="relative overflow-hidden rounded-3xl border border-gray-200 bg-gradient-to-br from-pink-200 via-white to-amber-100 shadow-2xl transition-transform duration-200 ease-out hover:-translate-y-1 dark:border-gray-800 dark:from-pink-900/40 dark:via-gray-900 dark:to-gray-950">
{featuredPost.featured_image && ( {featuredPost.featured_image && (
<img <img
src={featuredPost.featured_image} src={featuredPost.featured_image}
@@ -229,82 +262,100 @@ const Blog: React.FC<Props> = ({ posts }) => {
</div> </div>
)} )}
</div> </div>
</div> </motion.div>
</div> </motion.div>
{gridPosts.length > 0 && ( {gridPosts.length > 0 && (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <motion.div
className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"
variants={stagger}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
{gridPosts.map((post) => ( {gridPosts.map((post) => (
<Card key={post.id} className="flex h-full flex-col overflow-hidden border-gray-200 shadow-sm transition hover:-translate-y-1 hover:shadow-lg dark:border-gray-800"> <motion.div key={post.id} variants={revealUp}>
{post.featured_image && ( <Card className="flex h-full flex-col overflow-hidden border-gray-200 shadow-sm transition-transform duration-200 ease-out hover:-translate-y-1 hover:shadow-lg dark:border-gray-800">
<div className="aspect-video"> {post.featured_image && (
<img src={post.featured_image} alt={post.title} className="h-full w-full object-cover" /> <div className="aspect-video">
</div> <img src={post.featured_image} alt={post.title} className="h-full w-full object-cover" />
)} </div>
<CardContent className="flex flex-1 flex-col space-y-4 p-6"> )}
<div className="space-y-2"> <CardContent className="flex flex-1 flex-col space-y-4 p-6">
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-50"> <div className="space-y-2">
{post.title || 'Untitled'} <h3 className="text-xl font-semibold text-gray-900 dark:text-gray-50">
</h3> {post.title || 'Untitled'}
<MarkdownPreview </h3>
html={post.excerpt_html} <MarkdownPreview
fallback={post.excerpt} html={post.excerpt_html}
className="text-sm leading-relaxed text-gray-600 dark:text-gray-300" fallback={post.excerpt}
/> className="text-sm leading-relaxed text-gray-600 dark:text-gray-300"
</div> />
<div className="flex-1" /> </div>
{renderPostMeta(post)} <div className="flex-1" />
<Button asChild variant="ghost" className="justify-start p-0 text-pink-600 hover:text-pink-700"> {renderPostMeta(post)}
<Link href={buildArticleHref(post.slug)}> <Button asChild variant="ghost" className="justify-start p-0 text-pink-600 hover:text-pink-700">
{t('blog.read_more')} <Link href={buildArticleHref(post.slug)}>
</Link> {t('blog.read_more')}
</Button> </Link>
</CardContent> </Button>
</Card> </CardContent>
</Card>
</motion.div>
))} ))}
</div> </motion.div>
)} )}
</div> </div>
); );
}; };
const renderListView = () => ( const renderListView = () => (
<div className="space-y-6"> <motion.div
className="space-y-6"
variants={stagger}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
{listPosts.map((post) => ( {listPosts.map((post) => (
<Card key={post.id} className="overflow-hidden border-gray-200 shadow-sm hover:shadow-lg dark:border-gray-800"> <motion.div key={post.id} variants={revealUp}>
<div className="grid gap-0 md:grid-cols-3"> <Card className="overflow-hidden border-gray-200 shadow-sm transition-transform duration-200 ease-out hover:-translate-y-1 hover:shadow-lg dark:border-gray-800">
{post.featured_image && ( <div className="grid gap-0 md:grid-cols-3">
<img {post.featured_image && (
src={post.featured_image} <img
alt={post.title} src={post.featured_image}
className="h-full w-full object-cover" alt={post.title}
/> className="h-full w-full object-cover"
)}
<CardContent className="md:col-span-2">
<div className="space-y-4 py-6">
<h3 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">
{post.title || 'Untitled'}
</h3>
<MarkdownPreview
html={post.excerpt_html}
fallback={post.excerpt}
className="text-gray-600 dark:text-gray-300"
/> />
{renderPostMeta(post)} )}
<Button asChild variant="outline" className="w-fit border-pink-200 text-pink-600 hover:bg-pink-50 dark:border-pink-500/40 dark:text-pink-200"> <CardContent className="md:col-span-2">
<Link href={buildArticleHref(post.slug)}> <div className="space-y-4 py-6">
{t('blog.read_more')} <h3 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">
</Link> {post.title || 'Untitled'}
</Button> </h3>
</div> <MarkdownPreview
</CardContent> html={post.excerpt_html}
</div> fallback={post.excerpt}
</Card> className="text-gray-600 dark:text-gray-300"
/>
{renderPostMeta(post)}
<Button asChild variant="outline" className="w-fit border-pink-200 text-pink-600 hover:bg-pink-50 dark:border-pink-500/40 dark:text-pink-200">
<Link href={buildArticleHref(post.slug)}>
{t('blog.read_more')}
</Link>
</Button>
</div>
</CardContent>
</div>
</Card>
</motion.div>
))} ))}
{listPosts.length === 0 && ( {listPosts.length === 0 && (
<Card className="p-8 text-center text-gray-600 dark:text-gray-300">{t('blog.empty')}</Card> <motion.div variants={revealUp}>
<Card className="p-8 text-center text-gray-600 dark:text-gray-300">{t('blog.empty')}</Card>
</motion.div>
)} )}
</div> </motion.div>
); );
return ( return (
@@ -312,29 +363,46 @@ const Blog: React.FC<Props> = ({ posts }) => {
<Head title={t('blog.title')} /> <Head title={t('blog.title')} />
<section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-12"> <section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-12">
<div className="container mx-auto max-w-3xl space-y-5 text-center"> <motion.div
<Badge variant="outline" className="border-pink-200 text-pink-600 dark:border-pink-500/50 dark:text-pink-200"> className="container mx-auto max-w-3xl space-y-5 text-center"
Fotospiel Blog variants={stagger}
</Badge> initial="hidden"
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-50 md:text-4xl">{t('blog.hero_title')}</h1> animate="visible"
<p className="text-base leading-relaxed text-gray-600 dark:text-gray-300 md:text-lg">{t('blog.hero_description')}</p> >
<div className="flex flex-wrap justify-center gap-2.5"> <motion.div variants={revealUp}>
<Badge variant="outline" className="border-pink-200 text-pink-600 dark:border-pink-500/50 dark:text-pink-200">
Fotospiel Blog
</Badge>
</motion.div>
<motion.h1 className="text-3xl font-bold text-gray-900 dark:text-gray-50 md:text-4xl" variants={revealUp}>
{t('blog.hero_title')}
</motion.h1>
<motion.p className="text-base leading-relaxed text-gray-600 dark:text-gray-300 md:text-lg" variants={revealUp}>
{t('blog.hero_description')}
</motion.p>
<motion.div className="flex flex-wrap justify-center gap-2.5" variants={revealUp}>
<Button asChild className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white px-6 py-2.5 text-base"> <Button asChild className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white px-6 py-2.5 text-base">
<Link href={localizedPath('/packages')}>{t('home.cta_explore')}</Link> <Link href={localizedPath('/packages')}>{t('home.cta_explore')}</Link>
</Button> </Button>
<Button asChild variant="outline" className="border-gray-300 text-gray-800 hover:bg-gray-100 px-6 py-2.5 text-base dark:border-gray-700 dark:text-gray-50 dark:hover:bg-gray-800"> <Button asChild variant="outline" className="border-gray-300 text-gray-800 hover:bg-gray-100 px-6 py-2.5 text-base dark:border-gray-700 dark:text-gray-50 dark:hover:bg-gray-800">
<Link href="#articles">{t('blog.hero_cta')}</Link> <Link href="#articles">{t('blog.hero_cta')}</Link>
</Button> </Button>
</div> </motion.div>
</div> </motion.div>
</section> </section>
<section id="articles" className="bg-white px-4 py-16 dark:bg-gray-950"> <section id="articles" className="bg-white px-4 py-16 dark:bg-gray-950 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-6xl space-y-12"> <div className="container mx-auto max-w-6xl space-y-12">
<div className="text-center"> <motion.div
className="text-center"
variants={revealUp}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
<h2 className="text-3xl font-bold text-gray-900 dark:text-gray-50">{t('blog.posts_title')}</h2> <h2 className="text-3xl font-bold text-gray-900 dark:text-gray-50">{t('blog.posts_title')}</h2>
<Separator className="mx-auto mt-4 w-24" /> <Separator className="mx-auto mt-4 w-24" />
</div> </motion.div>
{isLandingLayout ? renderLandingGrid() : renderListView()} {isLandingLayout ? renderLandingGrid() : renderListView()}

View File

@@ -7,6 +7,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { motion, useReducedMotion } from 'framer-motion';
interface AdjacentPost { interface AdjacentPost {
slug: string; slug: string;
@@ -58,6 +59,20 @@ const BlogShow: React.FC<Props> = ({ post }) => {
const [copied, setCopied] = React.useState(false); const [copied, setCopied] = React.useState(false);
const locale = i18n.language || 'de'; const locale = i18n.language || 'de';
const dateLocale = locale === 'en' ? 'en-US' : 'de-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(() => { const formattedDate = React.useMemo(() => {
try { try {
@@ -121,9 +136,9 @@ const BlogShow: React.FC<Props> = ({ post }) => {
<MarketingLayout title={`${post.title} ${t('title_suffix')}`}> <MarketingLayout title={`${post.title} ${t('title_suffix')}`}>
<Head title={`${post.title} ${t('title_suffix')}`} /> <Head title={`${post.title} ${t('title_suffix')}`} />
<section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-8 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-10"> <section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-8 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-10 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-5xl space-y-5"> <motion.div className="container mx-auto max-w-5xl space-y-5" variants={stagger} initial="hidden" animate="visible">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400"> <motion.div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400" variants={revealUp}>
<nav className="flex flex-wrap items-center gap-2"> <nav className="flex flex-wrap items-center gap-2">
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100"> <Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
{t('breadcrumb_home')} {t('breadcrumb_home')}
@@ -138,51 +153,59 @@ const BlogShow: React.FC<Props> = ({ post }) => {
<Link href={localizedPath('/blog')} className="text-pink-600 hover:text-pink-700"> <Link href={localizedPath('/blog')} className="text-pink-600 hover:text-pink-700">
{t('back_to_blog')} {t('back_to_blog')}
</Link> </Link>
</div> </motion.div>
<Card className="rounded-[32px] border border-white/60 bg-white/80 text-gray-900 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80 dark:text-gray-50"> <motion.div variants={revealUp}>
<CardContent className="space-y-6 p-6 md:p-8"> <Card className="rounded-[32px] border border-white/60 bg-white/80 text-gray-900 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80 dark:text-gray-50">
<div className="flex flex-col gap-6 md:grid md:grid-cols-[minmax(0,1fr)_240px] md:items-center md:gap-8"> <CardContent className="space-y-6 p-6 md:p-8">
<div className="space-y-4 text-left"> <div className="flex flex-col gap-6 md:grid md:grid-cols-[minmax(0,1fr)_240px] md:items-center md:gap-8">
<Badge variant="outline" className="border-pink-200 bg-white/70 text-pink-600 dark:border-pink-500/60 dark:bg-gray-900/80 dark:text-pink-200"> <div className="space-y-4 text-left">
Fotospiel Stories <Badge variant="outline" className="border-pink-200 bg-white/70 text-pink-600 dark:border-pink-500/60 dark:bg-gray-900/80 dark:text-pink-200">
</Badge>
<h1 className="text-3xl font-bold leading-tight text-gray-900 dark:text-gray-50 md:text-4xl">{post.title}</h1>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-600 dark:text-gray-300">
<span>{t('by_author')} {post.author?.name || t('team')}</span>
<span className="hidden text-gray-400 sm:inline"></span>
<span>{t('published_on')} {formattedDate}</span>
</div>
<MarkdownPreview
html={post.excerpt_html}
fallback={post.excerpt}
className="text-base leading-relaxed text-gray-700 dark:text-gray-100"
/>
</div>
<div className="relative">
{post.featured_image ? (
<div className="rounded-3xl border border-white/80 bg-white/60 p-2 shadow-2xl dark:border-gray-800/80 dark:bg-gray-900/80">
<img
src={post.featured_image}
alt={post.title}
className="h-full w-full rounded-2xl object-cover"
/>
</div>
) : (
<div className="rounded-3xl border border-dashed border-pink-200 bg-gradient-to-br from-pink-100 via-white to-amber-100 p-8 text-center text-lg font-semibold text-pink-700 shadow-lg dark:border-pink-500/40 dark:from-pink-900/30 dark:via-gray-900 dark:to-gray-900">
Fotospiel Stories Fotospiel Stories
</Badge>
<h1 className="text-3xl font-bold leading-tight text-gray-900 dark:text-gray-50 md:text-4xl">{post.title}</h1>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-600 dark:text-gray-300">
<span>{t('by_author')} {post.author?.name || t('team')}</span>
<span className="hidden text-gray-400 sm:inline"></span>
<span>{t('published_on')} {formattedDate}</span>
</div> </div>
)} <MarkdownPreview
html={post.excerpt_html}
fallback={post.excerpt}
className="text-base leading-relaxed text-gray-700 dark:text-gray-100"
/>
</div>
<div className="relative transition-transform duration-200 ease-out hover:-translate-y-1">
{post.featured_image ? (
<div className="rounded-3xl border border-white/80 bg-white/60 p-2 shadow-2xl dark:border-gray-800/80 dark:bg-gray-900/80">
<img
src={post.featured_image}
alt={post.title}
className="h-full w-full rounded-2xl object-cover"
/>
</div>
) : (
<div className="rounded-3xl border border-dashed border-pink-200 bg-gradient-to-br from-pink-100 via-white to-amber-100 p-8 text-center text-lg font-semibold text-pink-700 shadow-lg dark:border-pink-500/40 dark:from-pink-900/30 dark:via-gray-900 dark:to-gray-900">
Fotospiel Stories
</div>
)}
</div>
</div> </div>
</div> </CardContent>
</CardContent> </Card>
</Card> </motion.div>
</div> </motion.div>
</section> </section>
<section className="bg-white px-4 py-12 dark:bg-gray-950"> <section className="bg-white px-4 py-12 dark:bg-gray-950 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-6xl gap-10 lg:grid lg:grid-cols-[minmax(0,1fr)_320px]"> <motion.div
<article className="rounded-[32px] border border-gray-100 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900"> className="container mx-auto max-w-6xl gap-10 lg:grid lg:grid-cols-[minmax(0,1fr)_320px]"
variants={stagger}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
<motion.article className="rounded-[32px] border border-gray-100 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900" variants={revealUp}>
<div <div
className="prose prose-lg prose-slate max-w-none className="prose prose-lg prose-slate max-w-none
prose-headings:text-slate-900 prose-headings:font-semibold prose-headings:text-slate-900 prose-headings:font-semibold
@@ -200,139 +223,151 @@ const BlogShow: React.FC<Props> = ({ post }) => {
dark:prose-li:text-gray-100 dark:prose-blockquote:border-pink-400" dark:prose-li:text-gray-100 dark:prose-blockquote:border-pink-400"
dangerouslySetInnerHTML={{ __html: post.content_html }} dangerouslySetInnerHTML={{ __html: post.content_html }}
/> />
</article> </motion.article>
<aside className="mt-10 space-y-6 lg:mt-0 lg:sticky lg:top-24"> <motion.aside className="mt-10 space-y-6 lg:mt-0 lg:sticky lg:top-24" variants={stagger}>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <motion.div variants={revealUp}>
<CardContent className="space-y-3 p-6"> <Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <CardContent className="space-y-3 p-6">
{t('summary_title')}
</p>
<MarkdownPreview
html={post.excerpt_html}
fallback={post.excerpt}
className="text-base leading-relaxed text-gray-700 dark:text-gray-100"
/>
</CardContent>
</Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-4 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('toc_title')}
</p>
{post.headings && post.headings.length > 0 ? (
<ul className="space-y-3 text-sm text-gray-700 dark:text-gray-100">
{post.headings.map((heading) => (
<li key={heading.slug} className="border-l-2 border-pink-100 pl-3 dark:border-pink-500/40">
<a href={`#${heading.slug}`} className="hover:text-pink-600">
{heading.text}
</a>
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">{t('toc_empty')}</p>
)}
</CardContent>
</Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('sidebar_author_title')}
</p>
<p className="text-lg font-semibold text-gray-900 dark:text-gray-50">{post.author?.name || t('team')}</p>
<p className="text-sm text-gray-600 dark:text-gray-300">{t('sidebar_author_description')}</p>
</CardContent>
</Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-4 p-6">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('share_title')} {t('summary_title')}
</p> </p>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('share_hint')}</p> <MarkdownPreview
</div> html={post.excerpt_html}
<div className="flex flex-wrap gap-3"> fallback={post.excerpt}
<Button variant="outline" size="sm" onClick={handleCopyLink} className="border-pink-200 text-pink-600 dark:border-pink-500/50 dark:text-pink-200"> className="text-base leading-relaxed text-gray-700 dark:text-gray-100"
{copied ? t('share_copied') : t('share_copy')} />
</Button> </CardContent>
{canUseNativeShare && ( </Card>
<Button </motion.div>
variant="ghost"
size="sm" <motion.div variants={revealUp}>
className="text-gray-700 hover:text-pink-600 dark:text-gray-200" <Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
onClick={() => navigator.share?.({ title: post.title, url: shareUrl })} <CardContent className="space-y-4 p-6">
> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('share_native')} {t('toc_title')}
</Button> </p>
{post.headings && post.headings.length > 0 ? (
<ul className="space-y-3 text-sm text-gray-700 dark:text-gray-100">
{post.headings.map((heading) => (
<li key={heading.slug} className="border-l-2 border-pink-100 pl-3 dark:border-pink-500/40">
<a href={`#${heading.slug}`} className="hover:text-pink-600">
{heading.text}
</a>
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">{t('toc_empty')}</p>
)} )}
</div> </CardContent>
<Separator className="my-2" /> </Card>
<div className="flex flex-col gap-2"> </motion.div>
{shareLinks.map((link) => (
<Button key={link.key} variant="outline" size="sm" asChild className="justify-start border-gray-200 text-gray-700 hover:border-pink-200 hover:text-pink-600 dark:border-gray-700 dark:text-gray-100"> <motion.div variants={revealUp}>
<a href={link.href} target="_blank" rel="noreferrer"> <Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
{link.label} <CardContent className="space-y-3 p-6">
</a> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('sidebar_author_title')}
</p>
<p className="text-lg font-semibold text-gray-900 dark:text-gray-50">{post.author?.name || t('team')}</p>
<p className="text-sm text-gray-600 dark:text-gray-300">{t('sidebar_author_description')}</p>
</CardContent>
</Card>
</motion.div>
<motion.div variants={revealUp}>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-4 p-6">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('share_title')}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('share_hint')}</p>
</div>
<div className="flex flex-wrap gap-3">
<Button variant="outline" size="sm" onClick={handleCopyLink} className="border-pink-200 text-pink-600 dark:border-pink-500/50 dark:text-pink-200">
{copied ? t('share_copied') : t('share_copy')}
</Button> </Button>
))} {canUseNativeShare && (
</div> <Button
</CardContent> variant="ghost"
</Card> size="sm"
</aside> className="text-gray-700 hover:text-pink-600 dark:text-gray-200"
</div> onClick={() => navigator.share?.({ title: post.title, url: shareUrl })}
>
{t('share_native')}
</Button>
)}
</div>
<Separator className="my-2" />
<div className="flex flex-col gap-2">
{shareLinks.map((link) => (
<Button key={link.key} variant="outline" size="sm" asChild className="justify-start border-gray-200 text-gray-700 hover:border-pink-200 hover:text-pink-600 dark:border-gray-700 dark:text-gray-100">
<a href={link.href} target="_blank" rel="noreferrer">
{link.label}
</a>
</Button>
))}
</div>
</CardContent>
</Card>
</motion.div>
</motion.aside>
</motion.div>
</section> </section>
{(post.previous_post || post.next_post) && ( {(post.previous_post || post.next_post) && (
<section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-12 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950"> <section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-12 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-6xl"> <motion.div className="container mx-auto max-w-6xl" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{post.previous_post && ( {post.previous_post && (
<Card className="border-none bg-gradient-to-br from-pink-500 to-pink-400 text-white shadow-lg"> <motion.div variants={revealUp}>
<CardContent className="space-y-4 p-6"> <Card className="border-none bg-gradient-to-br from-pink-500 to-pink-400 text-white shadow-lg">
<p className="text-sm font-semibold uppercase tracking-wide text-white/80"> <CardContent className="space-y-4 p-6">
{t('previous_post')} <p className="text-sm font-semibold uppercase tracking-wide text-white/80">
</p> {t('previous_post')}
<h3 className="text-2xl font-semibold">{post.previous_post.title}</h3> </p>
<MarkdownPreview <h3 className="text-2xl font-semibold">{post.previous_post.title}</h3>
html={post.previous_post.excerpt_html} <MarkdownPreview
fallback={post.previous_post.excerpt} html={post.previous_post.excerpt_html}
className="text-white/90" fallback={post.previous_post.excerpt}
/> className="text-white/90"
<Button asChild variant="secondary" className="bg-white/20 text-white hover:bg-white/30"> />
<Link href={buildArticleHref(post.previous_post.slug)}> <Button asChild variant="secondary" className="bg-white/20 text-white hover:bg-white/30">
{t('read_story')} <Link href={buildArticleHref(post.previous_post.slug)}>
</Link> {t('read_story')}
</Button> </Link>
</CardContent> </Button>
</Card> </CardContent>
</Card>
</motion.div>
)} )}
{post.next_post && ( {post.next_post && (
<Card className="border-none bg-gradient-to-br from-amber-400 to-pink-400 text-gray-900 shadow-lg"> <motion.div variants={revealUp}>
<CardContent className="space-y-4 p-6"> <Card className="border-none bg-gradient-to-br from-amber-400 to-pink-400 text-gray-900 shadow-lg">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-800/80"> <CardContent className="space-y-4 p-6">
{t('next_post')} <p className="text-sm font-semibold uppercase tracking-wide text-gray-800/80">
</p> {t('next_post')}
<h3 className="text-2xl font-semibold">{post.next_post.title}</h3> </p>
<MarkdownPreview <h3 className="text-2xl font-semibold">{post.next_post.title}</h3>
html={post.next_post.excerpt_html} <MarkdownPreview
fallback={post.next_post.excerpt} html={post.next_post.excerpt_html}
className="text-gray-900/80" fallback={post.next_post.excerpt}
/> className="text-gray-900/80"
<Button asChild className="bg-gray-900 text-white hover:bg-gray-800"> />
<Link href={buildArticleHref(post.next_post.slug)}> <Button asChild className="bg-gray-900 text-white hover:bg-gray-800">
{t('read_story')} <Link href={buildArticleHref(post.next_post.slug)}>
</Link> {t('read_story')}
</Button> </Link>
</CardContent> </Button>
</Card> </CardContent>
</Card>
</motion.div>
)} )}
</div> </div>
</div> </motion.div>
</section> </section>
)} )}
</MarketingLayout> </MarketingLayout>

View File

@@ -10,6 +10,7 @@ import { Head, Link } from '@inertiajs/react';
import { CheckCircle2, Sparkles } from 'lucide-react'; import { CheckCircle2, Sparkles } from 'lucide-react';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { motion, useReducedMotion } from 'framer-motion';
type DemoFeature = { title: string; description: string }; type DemoFeature = { title: string; description: string };
@@ -21,6 +22,7 @@ const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();
const locale = useLocale(); const locale = useLocale();
const shouldReduceMotion = useReducedMotion();
const embedUrl = demoToken ? `/e/${demoToken}` : '/e/demo?demo=1'; const embedUrl = demoToken ? `/e/${demoToken}` : '/e/demo?demo=1';
const [isDemoOpen, setIsDemoOpen] = React.useState(false); const [isDemoOpen, setIsDemoOpen] = React.useState(false);
const demoOpenLabel = t('labels.demoOpenOverlay', locale === 'en' ? 'Open demo overlay' : 'Demo im Overlay öffnen'); const demoOpenLabel = t('labels.demoOpenOverlay', locale === 'en' ? 'Open demo overlay' : 'Demo im Overlay öffnen');
@@ -38,6 +40,25 @@ const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
const handleOpenDemo = (): void => { const handleOpenDemo = (): void => {
setIsDemoOpen(true); 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 ( return (
<MarketingLayout title={demo.title}> <MarketingLayout title={demo.title}>
@@ -74,28 +95,46 @@ const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
<div className="flex-1"> <div className="flex-1">
{embedUrl ? ( {embedUrl ? (
<> <>
<div className="relative mx-auto w-[min(92vw,calc(75dvh*10/14))] aspect-[10/14] rounded-[2.5rem] border border-gray-200 bg-gray-900 px-3 pb-3 pt-4 shadow-2xl dark:border-gray-700"> <div className="mx-auto w-[min(92vw,calc(75dvh*10/14))] aspect-[10/14]" style={{ perspective: 1200 }}>
<div <motion.div
className="absolute top-2 left-1/2 h-1.5 w-16 -translate-x-1/2 rounded-full bg-gray-300 dark:bg-gray-600" className="relative h-full w-full rounded-[2.5rem] border border-gray-200 bg-gray-900 px-3 pb-3 pt-4 shadow-2xl dark:border-gray-700"
aria-hidden initial="rest"
/> animate="rest"
<div className="h-full w-full overflow-hidden rounded-[1.75rem] bg-white shadow-inner dark:bg-gray-950"> whileHover="hover"
{isDemoOpen ? ( variants={frameVariants}
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-gradient-to-br from-pink-200 via-white to-white px-4 text-center dark:from-pink-900/40 dark:via-gray-950 dark:to-gray-950"> style={{ transformStyle: 'preserve-3d' }}
<p className="text-xs uppercase tracking-[0.2em] text-pink-600/70 dark:text-pink-300/70">Live Demo</p> >
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Demo läuft im Overlay</p> <motion.span
<p className="text-xs text-gray-600 dark:text-gray-300">Schließe das Overlay, um hier weiterzumachen.</p> className="pointer-events-none absolute inset-0 rounded-[2.5rem]"
</div> variants={shineVariants}
) : ( style={{
<iframe background:
title="Demo der Fotospiel App" 'linear-gradient(120deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 50%, rgba(255,255,255,0) 100%)',
src={embedUrl} mixBlendMode: 'screen',
className="h-full w-full border-0 bg-white dark:bg-gray-950" }}
loading="lazy" />
sandbox="allow-scripts allow-same-origin allow-forms" <div
/> className="absolute top-2 left-1/2 h-1.5 w-16 -translate-x-1/2 rounded-full bg-gray-300 dark:bg-gray-600"
)} aria-hidden
</div> />
<div className="h-full w-full overflow-hidden rounded-[1.75rem] bg-white shadow-inner dark:bg-gray-950">
{isDemoOpen ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-gradient-to-br from-pink-200 via-white to-white px-4 text-center dark:from-pink-900/40 dark:via-gray-950 dark:to-gray-950">
<p className="text-xs uppercase tracking-[0.2em] text-pink-600/70 dark:text-pink-300/70">Live Demo</p>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Demo läuft im Overlay</p>
<p className="text-xs text-gray-600 dark:text-gray-300">Schließe das Overlay, um hier weiterzumachen.</p>
</div>
) : (
<iframe
title="Demo der Fotospiel App"
src={embedUrl}
className="h-full w-full border-0 bg-white dark:bg-gray-950"
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-forms"
/>
)}
</div>
</motion.div>
</div> </div>
<div className="mt-4 flex flex-col items-center gap-1 text-center"> <div className="mt-4 flex flex-col items-center gap-1 text-center">
<p className="text-sm text-gray-600 dark:text-gray-300">{demo.iframeNote}</p> <p className="text-sm text-gray-600 dark:text-gray-300">{demo.iframeNote}</p>
@@ -143,17 +182,19 @@ const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
<div className="mx-auto max-w-5xl"> <div className="mx-auto max-w-5xl">
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
{demoFeatures.map((feature) => ( {demoFeatures.map((feature) => (
<Card key={feature.title} className="border-gray-100 shadow-sm dark:border-gray-800"> <motion.div key={feature.title} whileHover={featureHover} transition={{ duration: 0.2, ease: 'easeOut' }}>
<CardHeader> <Card className="border-gray-100 shadow-sm dark:border-gray-800">
<CardTitle className="flex items-center gap-2 text-lg"> <CardHeader>
<Sparkles className="h-5 w-5 text-pink-500" aria-hidden /> <CardTitle className="flex items-center gap-2 text-lg">
{feature.title} <Sparkles className="h-5 w-5 text-pink-500" aria-hidden />
</CardTitle> {feature.title}
</CardHeader> </CardTitle>
<CardContent> </CardHeader>
<CardDescription className="text-sm text-gray-600 dark:text-gray-300">{feature.description}</CardDescription> <CardContent>
</CardContent> <CardDescription className="text-sm text-gray-600 dark:text-gray-300">{feature.description}</CardDescription>
</Card> </CardContent>
</Card>
</motion.div>
))} ))}
</div> </div>

View File

@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { ArrowRight, Camera, QrCode, ShieldCheck, Sparkles, Smartphone, Loader2, CheckCircle2 } from 'lucide-react'; import { ArrowRight, Camera, QrCode, ShieldCheck, Sparkles, Smartphone, Loader2, CheckCircle2 } from 'lucide-react';
import { usePage } from '@inertiajs/react'; import { usePage } from '@inertiajs/react';
import { motion, useReducedMotion } from 'framer-motion';
interface Package { interface Package {
id: number; id: number;
@@ -36,6 +37,7 @@ const Home: React.FC<Props> = ({ packages }) => {
trackClick: trackHeroCtaClick, trackClick: trackHeroCtaClick,
} = useCtaExperiment('home_hero_cta'); } = useCtaExperiment('home_hero_cta');
const { flash } = usePage<{ flash?: { success?: string } }>().props; const { flash } = usePage<{ flash?: { success?: string } }>().props;
const shouldReduceMotion = useReducedMotion();
const { data, setData, post, processing, errors, reset } = useForm({ const { data, setData, post, processing, errors, reset } = useForm({
name: '', name: '',
email: '', email: '',
@@ -43,6 +45,52 @@ const Home: React.FC<Props> = ({ packages }) => {
nickname: '', 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 heroBulletsRaw = t('home.hero_bullets', { returnObjects: true });
const heroBullets = Array.isArray(heroBulletsRaw) ? (heroBulletsRaw as string[]) : []; const heroBullets = Array.isArray(heroBulletsRaw) ? (heroBulletsRaw as string[]) : [];
@@ -109,34 +157,53 @@ const Home: React.FC<Props> = ({ packages }) => {
<section id="hero" className="bg-aurora-enhanced py-20 px-4 text-gray-900 dark:text-gray-100"> <section id="hero" className="bg-aurora-enhanced py-20 px-4 text-gray-900 dark:text-gray-100">
<div className="container mx-auto flex max-w-6xl flex-col items-center gap-12 md:flex-row"> <div className="container mx-auto flex max-w-6xl flex-col items-center gap-12 md:flex-row">
<div className="flex flex-col gap-8 text-center md:w-1/2 md:text-left"> <motion.div
className="flex flex-col gap-8 text-center md:w-1/2 md:text-left"
variants={stagger}
initial="hidden"
animate="visible"
>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Badge className="mx-auto w-fit bg-white/80 px-3 py-1 text-xs font-semibold uppercase text-rose-500 shadow-sm md:mx-0 md:text-[0.72rem]"> <motion.div variants={revealFadeIn}>
{t('home.hero_tagline')} <Badge className="mx-auto w-fit bg-white/80 px-3 py-1 text-xs font-semibold uppercase text-rose-500 shadow-sm md:mx-0 md:text-[0.72rem]">
</Badge> {t('home.hero_tagline')}
<h1 className="font-display text-4xl font-bold leading-tight md:text-5xl lg:text-6xl"> </Badge>
{t('home.hero_title')} </motion.div>
</h1> <motion.div>
<p className="text-lg text-gray-700 dark:text-gray-200 md:text-xl"> <motion.h1 className="font-display text-4xl font-bold leading-tight md:text-5xl lg:text-6xl" variants={revealFadeIn}>
{t('home.hero_description')} {t('home.hero_title')}
</p> </motion.h1>
</motion.div>
<motion.div>
<motion.p className="text-lg text-gray-700 dark:text-gray-200 md:text-xl" variants={revealFadeIn}>
{t('home.hero_description')}
</motion.p>
</motion.div>
{heroBullets.length > 0 && ( {heroBullets.length > 0 && (
<ul className="mx-auto flex flex-col gap-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-200 md:mx-0"> <motion.div>
{heroBullets.map((item, index) => { <motion.ul
const Icon = heroBulletIcons[index % heroBulletIcons.length] ?? Sparkles; className="mx-auto flex flex-col gap-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-200 md:mx-0"
return ( variants={stagger}
<li key={`hero-bullet-${index}`} className="flex items-start gap-3"> >
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/80 text-rose-500 shadow-sm dark:bg-gray-900/70"> {heroBullets.map((item, index) => {
<Icon className="h-4 w-4" aria-hidden /> const Icon = heroBulletIcons[index % heroBulletIcons.length] ?? Sparkles;
</span> return (
<span className="flex-1 text-base">{item}</span> <motion.li key={`hero-bullet-${index}`} className="flex items-start gap-3" variants={revealFadeIn}>
</li> <span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/80 text-rose-500 shadow-sm dark:bg-gray-900/70">
); <Icon className="h-4 w-4" aria-hidden />
})} </span>
</ul> <span className="flex-1 text-base">{item}</span>
</motion.li>
);
})}
</motion.ul>
</motion.div>
)} )}
</div> </div>
<div className="flex flex-wrap items-center justify-center gap-3 md:justify-start"> <motion.div
className="flex flex-wrap items-center justify-center gap-3 md:justify-start"
variants={revealFadeIn}
>
<Button <Button
asChild asChild
size="lg" size="lg"
@@ -193,16 +260,45 @@ const Home: React.FC<Props> = ({ packages }) => {
> >
{heroTertiaryLabel} {heroTertiaryLabel}
</Link> </Link>
</div> </motion.div>
</div> </motion.div>
<div className="relative w-full max-w-xl md:w-1/2"> <motion.div
<div className="absolute inset-0 rounded-3xl bg-white/40 blur-xl" aria-hidden /> className="relative w-full max-w-xl md:w-1/2"
<img variants={revealFade}
src="/joyous_wedding_guests_posing.jpg" initial="hidden"
alt={t('home.hero_image_alt')} animate="visible"
className="relative w-full rounded-[32px] border border-white/60 shadow-2xl" style={{ perspective: 1200 }}
/> >
</div> <motion.div initial="rest" animate="rest" whileHover="hover">
<motion.div
className="absolute inset-0 rounded-3xl bg-white/40 blur-xl"
aria-hidden
variants={heroGlowVariants}
/>
<motion.div
className="relative overflow-hidden rounded-[32px] border border-white/60 shadow-2xl"
variants={heroCardVariants}
style={{ transformStyle: 'preserve-3d' }}
>
<motion.span
className="pointer-events-none absolute inset-0"
variants={heroShineVariants}
style={{
background: 'linear-gradient(120deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.65) 50%, rgba(255,255,255,0) 100%)',
mixBlendMode: 'screen',
}}
/>
<motion.img
src="/joyous_wedding_guests_posing.jpg"
alt={t('home.hero_image_alt')}
className="h-full w-full scale-[1.04] object-cover"
initial={shouldReduceMotion ? false : { opacity: 0, scale: 1.02 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
/>
</motion.div>
</motion.div>
</motion.div>
</div> </div>
</section> </section>
@@ -216,20 +312,25 @@ const Home: React.FC<Props> = ({ packages }) => {
</div> </div>
<div className="mt-12 grid gap-6 md:grid-cols-3"> <div className="mt-12 grid gap-6 md:grid-cols-3">
{howSteps.map(({ icon: Icon, title, description }, index) => ( {howSteps.map(({ icon: Icon, title, description }, index) => (
<Card <motion.div
key={`how-step-${index}`} key={`how-step-${index}`}
className="border-gray-200/70 bg-white/90 shadow-md shadow-gray-200/40 transition hover:-translate-y-1 hover:shadow-lg hover:shadow-rose-200/30 dark:border-gray-800/60 dark:bg-gray-900/60" variants={revealUp}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
> >
<CardHeader className="flex flex-col gap-4"> <Card className="border-gray-200/70 bg-white/90 shadow-md shadow-gray-200/40 transition hover:-translate-y-1 hover:shadow-lg hover:shadow-rose-200/30 dark:border-gray-800/60 dark:bg-gray-900/60">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-rose-100/80 text-rose-500 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-100"> <CardHeader className="flex flex-col gap-4">
<Icon className="h-6 w-6" aria-hidden /> <span className="flex h-12 w-12 items-center justify-center rounded-full bg-rose-100/80 text-rose-500 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-100">
</span> <Icon className="h-6 w-6" aria-hidden />
<CardTitle className="text-xl">{title}</CardTitle> </span>
<CardDescription className="text-sm leading-relaxed text-slate-600 dark:text-slate-300"> <CardTitle className="text-xl">{title}</CardTitle>
{description} <CardDescription className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
</CardDescription> {description}
</CardHeader> </CardDescription>
</Card> </CardHeader>
</Card>
</motion.div>
))} ))}
</div> </div>
</div> </div>
@@ -238,36 +339,52 @@ const Home: React.FC<Props> = ({ packages }) => {
<section className="bg-slate-950 py-20 px-4 text-white"> <section className="bg-slate-950 py-20 px-4 text-white">
<div className="container mx-auto max-w-6xl"> <div className="container mx-auto max-w-6xl">
<div className="grid gap-10 md:grid-cols-[1.1fr_0.9fr] md:items-center"> <div className="grid gap-10 md:grid-cols-[1.1fr_0.9fr] md:items-center">
<div className="flex flex-col gap-6"> <motion.div
className="flex flex-col gap-6"
variants={stagger}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
<Badge className="w-fit bg-white/15 px-3 py-1 text-xs uppercase tracking-[0.35em] text-white/80"> <Badge className="w-fit bg-white/15 px-3 py-1 text-xs uppercase tracking-[0.35em] text-white/80">
{t('home.demo_title')} {t('home.demo_title')}
</Badge> </Badge>
<h2 className="font-display text-3xl font-semibold leading-tight md:text-4xl"> <motion.h2 className="font-display text-3xl font-semibold leading-tight md:text-4xl" variants={revealUp}>
{t('home.demo_description')} {t('home.demo_description')}
</h2> </motion.h2>
<p className="text-sm text-white/75">{t('home.demo_hint')}</p> <motion.p className="text-sm text-white/75" variants={revealUp}>
<Button {t('home.demo_hint')}
asChild </motion.p>
size="lg" <motion.div variants={revealUp}>
className="h-12 w-fit rounded-full bg-white px-7 text-base font-semibold text-slate-900 shadow-lg shadow-white/30 transition hover:bg-white/90" <Button
> asChild
<Link size="lg"
href={localizedPath('/demo')} className="h-12 w-fit rounded-full bg-white px-7 text-base font-semibold text-slate-900 shadow-lg shadow-white/30 transition hover:bg-white/90"
onClick={() =>
trackEvent({
category: 'marketing_home',
action: 'demo_section_cta',
})
}
className="flex items-center gap-2"
> >
<span>{t('home.demo_cta')}</span> <Link
<ArrowRight className="h-4 w-4" aria-hidden /> href={localizedPath('/demo')}
</Link> onClick={() =>
</Button> trackEvent({
</div> category: 'marketing_home',
<div className="relative mx-auto w-full max-w-sm"> action: 'demo_section_cta',
<div className="absolute inset-0 rounded-[42px] bg-gradient-to-br from-rose-400 via-purple-500 to-indigo-500 opacity-80 blur-2xl" aria-hidden /> })
}
className="flex items-center gap-2"
>
<span>{t('home.demo_cta')}</span>
<ArrowRight className="h-4 w-4" aria-hidden />
</Link>
</Button>
</motion.div>
</motion.div>
<motion.div
className="relative mx-auto w-full max-w-sm"
variants={revealUp}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
<motion.div className="absolute inset-0 rounded-[42px] bg-gradient-to-br from-rose-400 via-purple-500 to-indigo-500 opacity-80 blur-2xl" aria-hidden />
<div className="relative aspect-[9/16] w-full overflow-hidden rounded-[42px] border border-white/20 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 shadow-[0_40px_90px_-30px_rgba(15,23,42,0.75)]"> <div className="relative aspect-[9/16] w-full overflow-hidden rounded-[42px] border border-white/20 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 shadow-[0_40px_90px_-30px_rgba(15,23,42,0.75)]">
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center"> <div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
<Smartphone className="h-12 w-12 text-white/60" aria-hidden /> <Smartphone className="h-12 w-12 text-white/60" aria-hidden />
@@ -286,7 +403,7 @@ const Home: React.FC<Props> = ({ packages }) => {
</Link> </Link>
</div> </div>
</div> </div>
</div> </motion.div>
</div> </div>
</div> </div>
</section> </section>
@@ -298,17 +415,22 @@ const Home: React.FC<Props> = ({ packages }) => {
</div> </div>
<div className="mt-12 grid gap-6 md:grid-cols-3"> <div className="mt-12 grid gap-6 md:grid-cols-3">
{features.map((feature, index) => ( {features.map((feature, index) => (
<Card <motion.div
key={`feature-${index}`} key={`feature-${index}`}
className="border-gray-200/80 bg-gray-50/80 shadow-sm shadow-rose-100/40 transition hover:-translate-y-1 hover:shadow-lg hover:shadow-rose-200/40 dark:border-gray-800/60 dark:bg-gray-900/70" variants={revealUp}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
> >
<CardHeader> <Card className="border-gray-200/80 bg-gray-50/80 shadow-sm shadow-rose-100/40 transition hover:-translate-y-1 hover:shadow-lg hover:shadow-rose-200/40 dark:border-gray-800/60 dark:bg-gray-900/70">
<CardTitle className="text-xl">{feature.title}</CardTitle> <CardHeader>
<CardDescription className="text-sm leading-relaxed text-slate-600 dark:text-slate-300"> <CardTitle className="text-xl">{feature.title}</CardTitle>
{feature.description} <CardDescription className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
</CardDescription> {feature.description}
</CardHeader> </CardDescription>
</Card> </CardHeader>
</Card>
</motion.div>
))} ))}
</div> </div>
</div> </div>
@@ -317,63 +439,67 @@ const Home: React.FC<Props> = ({ packages }) => {
<section className="bg-gray-50 py-20 px-4 dark:bg-gray-950/80"> <section className="bg-gray-50 py-20 px-4 dark:bg-gray-950/80">
<div className="container mx-auto max-w-6xl"> <div className="container mx-auto max-w-6xl">
<div className="grid gap-10 lg:grid-cols-[1.15fr_0.85fr]"> <div className="grid gap-10 lg:grid-cols-[1.15fr_0.85fr]">
<Card className="border-rose-200/50 bg-white/95 shadow-md shadow-rose-200/40 dark:border-rose-500/30 dark:bg-gray-900/80"> <motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<CardHeader className="flex flex-col gap-4"> <Card className="border-rose-200/50 bg-white/95 shadow-md shadow-rose-200/40 dark:border-rose-500/30 dark:bg-gray-900/80">
<Badge className="w-fit bg-rose-100 px-3 py-1 text-xs font-semibold uppercase text-rose-600 dark:bg-rose-500/20 dark:text-rose-200"> <CardHeader className="flex flex-col gap-4">
{t('home.occasions_title')} <Badge className="w-fit bg-rose-100 px-3 py-1 text-xs font-semibold uppercase text-rose-600 dark:bg-rose-500/20 dark:text-rose-200">
</Badge> {t('home.occasions_title')}
<CardTitle className="text-2xl">{t('home.occasions_description')}</CardTitle> </Badge>
</CardHeader> <CardTitle className="text-2xl">{t('home.occasions_description')}</CardTitle>
<CardContent className="grid gap-3 sm:grid-cols-2"> </CardHeader>
{occasionLinks.map(({ key, href }) => ( <CardContent className="grid gap-3 sm:grid-cols-2">
<Link {occasionLinks.map(({ key, href }) => (
key={key} <Link
href={href} key={key}
onClick={() => href={href}
trackEvent({ onClick={() =>
category: 'marketing_home', trackEvent({
action: 'occasion_tile_click', category: 'marketing_home',
name: key, 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" }
> 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"
<ArrowRight className="h-4 w-4 -translate-x-1 transition group-hover:translate-x-0" aria-hidden /> >
<span>{t(`home.occasions.${key}`)}</span> <ArrowRight className="h-4 w-4 -translate-x-1 transition group-hover:translate-x-0" aria-hidden />
</Link> <span>{t(`home.occasions.${key}`)}</span>
))} </Link>
</CardContent> ))}
</Card> </CardContent>
</Card>
</motion.div>
<Card className="border-gray-200/70 bg-white/95 shadow-md shadow-gray-200/40 transition hover:-translate-y-1 hover:shadow-lg hover:shadow-rose-200/40 dark:border-gray-800/60 dark:bg-gray-900/80"> <motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<CardHeader> <Card className="border-gray-200/70 bg-white/95 shadow-md shadow-gray-200/40 transition hover:-translate-y-1 hover:shadow-lg hover:shadow-rose-200/40 dark:border-gray-800/60 dark:bg-gray-900/80">
<CardTitle className="text-2xl">{t('home.blog_teaser_title')}</CardTitle> <CardHeader>
<CardDescription className="text-sm leading-relaxed text-muted-foreground"> <CardTitle className="text-2xl">{t('home.blog_teaser_title')}</CardTitle>
{t('home.blog_teaser_description')} <CardDescription className="text-sm leading-relaxed text-muted-foreground">
</CardDescription> {t('home.blog_teaser_description')}
</CardHeader> </CardDescription>
<CardFooter className="px-6 pb-6"> </CardHeader>
<Button <CardFooter className="px-6 pb-6">
asChild <Button
variant="ghost" asChild
className="group inline-flex items-center gap-2 rounded-full px-0 text-base font-semibold text-rose-500 transition hover:text-rose-600 dark:text-rose-200 dark:hover:text-rose-100" variant="ghost"
> className="group inline-flex items-center gap-2 rounded-full px-0 text-base font-semibold text-rose-500 transition hover:text-rose-600 dark:text-rose-200 dark:hover:text-rose-100"
<Link
href={localizedPath('/blog')}
onClick={() =>
trackEvent({
category: 'marketing_home',
action: 'blog_teaser_cta',
})
}
className="flex items-center gap-2"
> >
<span>{t('home.blog_teaser_cta')}</span> <Link
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" aria-hidden /> href={localizedPath('/blog')}
</Link> onClick={() =>
</Button> trackEvent({
</CardFooter> category: 'marketing_home',
</Card> action: 'blog_teaser_cta',
})
}
className="flex items-center gap-2"
>
<span>{t('home.blog_teaser_cta')}</span>
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" aria-hidden />
</Link>
</Button>
</CardFooter>
</Card>
</motion.div>
</div> </div>
</div> </div>
</section> </section>
@@ -408,40 +534,45 @@ const Home: React.FC<Props> = ({ packages }) => {
</div> </div>
<div className="mt-10 grid gap-8 md:grid-cols-2"> <div className="mt-10 grid gap-8 md:grid-cols-2">
{packages.slice(0, 2).map((pkg) => ( {packages.slice(0, 2).map((pkg) => (
<Card <motion.div
key={pkg.id} key={pkg.id}
className="border-gray-200 bg-white/95 text-center shadow-md shadow-rose-200/30 transition hover:-translate-y-1 hover:shadow-xl hover:shadow-rose-200/40 dark:border-gray-800 dark:bg-gray-900/80" variants={revealUp}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
> >
<CardHeader className="gap-4"> <Card className="border-gray-200 bg-white/95 text-center shadow-md shadow-rose-200/30 transition hover:-translate-y-1 hover:shadow-xl hover:shadow-rose-200/40 dark:border-gray-800 dark:bg-gray-900/80">
<CardTitle className="text-2xl">{pkg.name}</CardTitle> <CardHeader className="gap-4">
<CardDescription className="text-sm text-muted-foreground"> <CardTitle className="text-2xl">{pkg.name}</CardTitle>
{pkg.description} <CardDescription className="text-sm text-muted-foreground">
</CardDescription> {pkg.description}
</CardHeader> </CardDescription>
<CardContent className="flex flex-col items-center gap-4"> </CardHeader>
<p className="text-3xl font-bold text-rose-500"> <CardContent className="flex flex-col items-center gap-4">
{pkg.price} {t('currency.euro')} <p className="text-3xl font-bold text-rose-500">
</p> {pkg.price} {t('currency.euro')}
<Button </p>
asChild <Button
className="rounded-full bg-rose-500 px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-rose-200/40 transition hover:bg-rose-600" asChild
> className="rounded-full bg-rose-500 px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-rose-200/40 transition hover:bg-rose-600"
<Link
href={`${localizedPath('/packages')}?package_id=${pkg.id}`}
onClick={() =>
trackEvent({
category: 'marketing_home',
action: 'package_teaser_cta',
name: pkg.name,
value: pkg.price,
})
}
> >
{t('home.view_details')} <Link
</Link> href={`${localizedPath('/packages')}?package_id=${pkg.id}`}
</Button> onClick={() =>
</CardContent> trackEvent({
</Card> category: 'marketing_home',
action: 'package_teaser_cta',
name: pkg.name,
value: pkg.price,
})
}
>
{t('home.view_details')}
</Link>
</Button>
</CardContent>
</Card>
</motion.div>
))} ))}
</div> </div>
</div> </div>

View File

@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { CheckCircle2, Images, Sparkles, Users } from 'lucide-react'; import { CheckCircle2, Images, Sparkles, Users } from 'lucide-react';
import { motion, useReducedMotion } from 'framer-motion';
type HeroStat = { value: string; label: string }; type HeroStat = { value: string; label: string };
type ExperienceStep = { title: string; description: string }; type ExperienceStep = { title: string; description: string };
@@ -42,6 +43,21 @@ const HowItWorks: React.FC = () => {
const { t, ready } = useTranslation('marketing'); const { t, ready } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();
const locale = useLocale(); 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) { if (!ready) {
return ( return (
@@ -139,17 +155,19 @@ const HowItWorks: React.FC = () => {
<section className="relative overflow-hidden bg-gradient-to-br from-pink-100 via-white to-white px-4 py-16 dark:from-pink-950/40 dark:via-gray-950 dark:to-gray-950"> <section className="relative overflow-hidden bg-gradient-to-br from-pink-100 via-white to-white px-4 py-16 dark:from-pink-950/40 dark:via-gray-950 dark:to-gray-950">
<div className="absolute -top-24 right-24 hidden h-64 w-64 rounded-full bg-pink-200/60 blur-3xl dark:bg-pink-900/50 lg:block" /> <div className="absolute -top-24 right-24 hidden h-64 w-64 rounded-full bg-pink-200/60 blur-3xl dark:bg-pink-900/50 lg:block" />
<div className="container mx-auto relative z-10 flex max-w-6xl flex-col gap-12 lg:flex-row lg:items-center"> <div className="container mx-auto relative z-10 flex max-w-6xl flex-col gap-12 lg:flex-row lg:items-center">
<div className="flex-1 space-y-6"> <motion.div className="flex-1 space-y-6" variants={stagger} initial="hidden" animate="visible">
<Badge variant="outline" className="border-pink-400 text-pink-600 dark:border-pink-500 dark:text-pink-300"> <motion.div variants={revealUp}>
Fotospiel Flow <Badge variant="outline" className="border-pink-400 text-pink-600 dark:border-pink-500 dark:text-pink-300">
</Badge> Fotospiel Flow
<h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-50 md:text-5xl"> </Badge>
</motion.div>
<motion.h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-50 md:text-5xl" variants={revealUp}>
{hero.title} {hero.title}
</h1> </motion.h1>
<p className="max-w-2xl text-lg text-gray-600 dark:text-gray-300"> <motion.p className="max-w-2xl text-lg text-gray-600 dark:text-gray-300" variants={revealUp}>
{hero.subtitle} {hero.subtitle}
</p> </motion.p>
<div className="flex flex-wrap items-center gap-3"> <motion.div className="flex flex-wrap items-center gap-3" variants={revealUp}>
<Button asChild size="lg" className="bg-pink-500 hover:bg-pink-600"> <Button asChild size="lg" className="bg-pink-500 hover:bg-pink-600">
<Link href={localizedPath('/packages')}> <Link href={localizedPath('/packages')}>
{hero.primaryCta} {hero.primaryCta}
@@ -170,23 +188,31 @@ const HowItWorks: React.FC = () => {
{t('packages.gift_cta', 'Paket verschenken')} {t('packages.gift_cta', 'Paket verschenken')}
</Link> </Link>
</Button> </Button>
</div> </motion.div>
</div> </motion.div>
<div className="flex-1"> <div className="flex-1">
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
{heroStats.map((stat) => ( {heroStats.map((stat) => (
<Card key={stat.label} className="border-pink-100/70 shadow-none dark:border-pink-900/40"> <motion.div
<CardHeader className="pb-2"> key={stat.label}
<CardTitle className="text-2xl font-semibold text-pink-600 dark:text-pink-300"> variants={revealUp}
{stat.value} initial="hidden"
</CardTitle> whileInView="visible"
</CardHeader> viewport={viewportOnce}
<CardContent> >
<CardDescription className="text-sm text-gray-600 dark:text-gray-300"> <Card className="border-pink-100/70 shadow-none dark:border-pink-900/40">
{stat.label} <CardHeader className="pb-2">
</CardDescription> <CardTitle className="text-2xl font-semibold text-pink-600 dark:text-pink-300">
</CardContent> {stat.value}
</Card> </CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-sm text-gray-600 dark:text-gray-300">
{stat.label}
</CardDescription>
</CardContent>
</Card>
</motion.div>
))} ))}
</div> </div>
</div> </div>
@@ -205,10 +231,14 @@ const HowItWorks: React.FC = () => {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="host" className="mt-6"> <TabsContent value="host" className="mt-6">
<ExperiencePanel data={experience.host} /> <motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<ExperiencePanel data={experience.host} />
</motion.div>
</TabsContent> </TabsContent>
<TabsContent value="guest" className="mt-6"> <TabsContent value="guest" className="mt-6">
<ExperiencePanel data={experience.guest} /> <motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<ExperiencePanel data={experience.guest} />
</motion.div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
@@ -226,19 +256,27 @@ const HowItWorks: React.FC = () => {
</div> </div>
<div className="mx-auto mt-10 grid max-w-6xl gap-6 md:grid-cols-2"> <div className="mx-auto mt-10 grid max-w-6xl gap-6 md:grid-cols-2">
{pillars.map((pillar) => ( {pillars.map((pillar) => (
<Card key={pillar.title} className="border-gray-100 shadow-sm dark:border-gray-800"> <motion.div
<CardHeader> key={pillar.title}
<CardTitle className="flex items-center gap-3 text-left text-xl"> variants={revealUp}
<Sparkles className="h-5 w-5 text-pink-500" aria-hidden /> initial="hidden"
{pillar.title} whileInView="visible"
</CardTitle> viewport={viewportOnce}
</CardHeader> >
<CardContent> <Card className="border-gray-100 shadow-sm dark:border-gray-800">
<p className="text-left text-gray-600 dark:text-gray-300"> <CardHeader>
{pillar.description} <CardTitle className="flex items-center gap-3 text-left text-xl">
</p> <Sparkles className="h-5 w-5 text-pink-500" aria-hidden />
</CardContent> {pillar.title}
</Card> </CardTitle>
</CardHeader>
<CardContent>
<p className="text-left text-gray-600 dark:text-gray-300">
{pillar.description}
</p>
</CardContent>
</Card>
</motion.div>
))} ))}
</div> </div>
</section> </section>
@@ -312,48 +350,50 @@ const HowItWorks: React.FC = () => {
</TabsList> </TabsList>
{useCases.tabs.map((tab) => ( {useCases.tabs.map((tab) => (
<TabsContent key={tab.value} value={tab.value} className="mt-6"> <TabsContent key={tab.value} value={tab.value} className="mt-6">
<Card className="border-gray-100 shadow-md dark:border-gray-800"> <motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <Card className="border-gray-100 shadow-md dark:border-gray-800">
<div> <CardHeader className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<CardTitle className="text-2xl"> <div>
{tab.label} <CardTitle className="text-2xl">
</CardTitle> {tab.label}
<CardDescription className="text-base text-gray-600 dark:text-gray-300"> </CardTitle>
{tab.goal} <CardDescription className="text-base text-gray-600 dark:text-gray-300">
</CardDescription> {tab.goal}
</div> </CardDescription>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-pink-100 dark:bg-pink-900/40">
{iconByUseCase[tab.value] ?? <Images className="h-6 w-6 text-pink-500" aria-hidden />}
</div>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300">
{t('how_it_works_page.labels.recommendations', 'Empfehlungen')}
</p>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
{tab.recommendations.map((item) => (
<li key={item} className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-pink-500" aria-hidden />
<span>{item}</span>
</li>
))}
</ul>
</div>
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300">
{t('how_it_works_page.labels.challenge_ideas', 'Ideen für Challenges')}
</p>
<div className="flex flex-wrap gap-2">
{tab.ideas.map((idea) => (
<Badge key={idea} variant="outline" className="border-pink-200 text-pink-600 dark:border-pink-900/40 dark:text-pink-300">
{idea}
</Badge>
))}
</div> </div>
</div> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-pink-100 dark:bg-pink-900/40">
</CardContent> {iconByUseCase[tab.value] ?? <Images className="h-6 w-6 text-pink-500" aria-hidden />}
</Card> </div>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300">
{t('how_it_works_page.labels.recommendations', 'Empfehlungen')}
</p>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
{tab.recommendations.map((item) => (
<li key={item} className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-pink-500" aria-hidden />
<span>{item}</span>
</li>
))}
</ul>
</div>
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300">
{t('how_it_works_page.labels.challenge_ideas', 'Ideen für Challenges')}
</p>
<div className="flex flex-wrap gap-2">
{tab.ideas.map((idea) => (
<Badge key={idea} variant="outline" className="border-pink-200 text-pink-600 dark:border-pink-900/40 dark:text-pink-300">
{idea}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
</motion.div>
</TabsContent> </TabsContent>
))} ))}
</Tabs> </Tabs>
@@ -362,29 +402,31 @@ const HowItWorks: React.FC = () => {
<section className="container mx-auto px-4 py-16"> <section className="container mx-auto px-4 py-16">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<Card className="border-gray-100 shadow-sm dark:border-gray-800"> <motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<CardHeader> <Card className="border-gray-100 shadow-sm dark:border-gray-800">
<CardTitle>{checklist.title}</CardTitle> <CardHeader>
<CardDescription> <CardTitle>{checklist.title}</CardTitle>
{t('how_it_works_page.labels.prep_hint', 'Alles, was du vor dem Event abhaken solltest.')} <CardDescription>
</CardDescription> {t('how_it_works_page.labels.prep_hint', 'Alles, was du vor dem Event abhaken solltest.')}
</CardHeader> </CardDescription>
<CardContent> </CardHeader>
<ul className="space-y-3 text-gray-600 dark:text-gray-300"> <CardContent>
{checklist.items.map((item) => ( <ul className="space-y-3 text-gray-600 dark:text-gray-300">
<li key={item} className="flex items-start gap-2"> {checklist.items.map((item) => (
<CheckCircle2 className="mt-0.5 h-5 w-5 text-pink-500" aria-hidden /> <li key={item} className="flex items-start gap-2">
<span>{item}</span> <CheckCircle2 className="mt-0.5 h-5 w-5 text-pink-500" aria-hidden />
</li> <span>{item}</span>
))} </li>
</ul> ))}
<Button asChild className="mt-6 bg-pink-500 hover:bg-pink-600"> </ul>
<Link href={localizedPath('/packages')}> <Button asChild className="mt-6 bg-pink-500 hover:bg-pink-600">
{checklist.cta} <Link href={localizedPath('/packages')}>
</Link> {checklist.cta}
</Button> </Link>
</CardContent> </Button>
</Card> </CardContent>
</Card>
</motion.div>
</div> </div>
</section> </section>

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import MarketingLayout from '@/layouts/mainWebsite'; import MarketingLayout from '@/layouts/mainWebsite';
import { Loader2, CheckCircle2 } from 'lucide-react'; import { Loader2, CheckCircle2 } from 'lucide-react';
import { motion, useReducedMotion } from 'framer-motion';
const Kontakt: React.FC = () => { const Kontakt: React.FC = () => {
const { data, setData, post, processing, errors, reset } = useForm({ const { data, setData, post, processing, errors, reset } = useForm({
@@ -16,6 +17,7 @@ const Kontakt: React.FC = () => {
const { flash } = usePage<{ flash?: { success?: string } }>().props; const { flash } = usePage<{ flash?: { success?: string } }>().props;
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();
const shouldReduceMotion = useReducedMotion();
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -34,7 +36,12 @@ const Kontakt: React.FC = () => {
<MarketingLayout title={t('kontakt.title')}> <MarketingLayout title={t('kontakt.title')}>
<Head title={t('kontakt.title')} /> <Head title={t('kontakt.title')} />
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-2xl mx-auto"> <motion.div
className="max-w-2xl mx-auto"
initial={shouldReduceMotion ? false : { opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
<h1 className="text-3xl font-bold text-center mb-8 font-display text-gray-900 dark:text-gray-100">{t('kontakt.title')}</h1> <h1 className="text-3xl font-bold text-center mb-8 font-display text-gray-900 dark:text-gray-100">{t('kontakt.title')}</h1>
<p className="text-center text-gray-600 dark:text-gray-300 mb-8 font-sans-marketing">{t('kontakt.description')}</p> <p className="text-center text-gray-600 dark:text-gray-300 mb-8 font-sans-marketing">{t('kontakt.description')}</p>
{flash?.success && ( {flash?.success && (
@@ -120,7 +127,7 @@ const Kontakt: React.FC = () => {
<div className="mt-8 text-center"> <div className="mt-8 text-center">
<Link href={localizedPath('/')} className="text-[#FFB6C1] hover:underline font-sans-marketing">{t('kontakt.back_home')}</Link> <Link href={localizedPath('/')} className="text-[#FFB6C1] hover:underline font-sans-marketing">{t('kontakt.back_home')}</Link>
</div> </div>
</div> </motion.div>
</div> </div>
</MarketingLayout> </MarketingLayout>
); );

View File

@@ -7,6 +7,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { motion, useReducedMotion } from 'framer-motion';
interface OccasionsProps { interface OccasionsProps {
type: string; type: string;
@@ -17,6 +18,20 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();
const locale = i18n.language || 'de'; const locale = i18n.language || 'de';
const isEn = locale.toLowerCase().startsWith('en'); 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 = { const occasionsContent = {
hochzeit: { hochzeit: {
@@ -123,9 +138,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<MarketingLayout title={content.title}> <MarketingLayout title={content.title}>
<Head title={content.title} /> <Head title={content.title} />
<section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14"> <section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-5xl space-y-8"> <motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400"> <motion.div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400" variants={revealUp}>
<nav className="flex flex-wrap items-center gap-2"> <nav className="flex flex-wrap items-center gap-2">
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100"> <Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
{t('nav.home')} {t('nav.home')}
@@ -138,10 +153,11 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700"> <Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700">
{t('nav.discover_packages')} {t('nav.discover_packages')}
</Link> </Link>
</div> </motion.div>
<Card className="rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"> <motion.div variants={revealUp}>
<CardContent className="space-y-8 p-6 md:p-10"> <Card className="relative overflow-hidden rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80">
<CardContent className="space-y-8 p-6 md:p-10">
<div className="flex flex-col gap-8 md:grid md:grid-cols-[minmax(0,1.3fr)_1fr] md:items-center"> <div className="flex flex-col gap-8 md:grid md:grid-cols-[minmax(0,1.3fr)_1fr] md:items-center">
<div className="space-y-5"> <div className="space-y-5">
<Badge <Badge
@@ -199,15 +215,16 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</div> </div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </motion.div>
</motion.div>
</section> </section>
<section className="bg-white px-4 py-14 dark:bg-gray-950"> <section className="bg-white px-4 py-14 dark:bg-gray-950 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-6xl space-y-10"> <motion.div className="container mx-auto max-w-6xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> <motion.div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]" variants={stagger}>
<div className="space-y-8"> <motion.div className="space-y-8" variants={revealUp}>
<div> <div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-50">{t('occasions.weddings.benefits_title')}</h2> <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-50">{t('occasions.weddings.benefits_title')}</h2>
<p className="mt-2 text-gray-600 dark:text-gray-300"> <p className="mt-2 text-gray-600 dark:text-gray-300">
@@ -254,9 +271,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<li>5. After the wedding, go through the gallery together and mark favorites for your album.</li> <li>5. After the wedding, go through the gallery together and mark favorites for your album.</li>
</ol> </ol>
</div> </div>
</div> </motion.div>
<aside className="space-y-6"> <motion.aside className="space-y-6" variants={revealUp}>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6"> <CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
@@ -309,51 +326,55 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>
</aside> </motion.aside>
</div> </motion.div>
<Separator className="my-8" /> <motion.div variants={revealUp}>
<Separator className="my-8" />
</motion.div>
<div className="grid gap-6 md:grid-cols-2"> <motion.div variants={revealUp}>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <div className="grid gap-6 md:grid-cols-2">
<CardContent className="space-y-3 p-6"> <Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <CardContent className="space-y-3 p-6">
What couples say <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
</p> What couples say
<p className="text-sm text-gray-700 dark:text-gray-200"> </p>
We ended up with more than 800 guest photos from little mishaps to the most emotional moments. Our <p className="text-sm text-gray-700 dark:text-gray-200">
photographer loved how well the Fotospiel App complemented her work. We ended up with more than 800 guest photos from little mishaps to the most emotional moments. Our
</p> photographer loved how well the Fotospiel App complemented her work.
</CardContent> </p>
</Card> </CardContent>
</Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6"> <CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Next step Next step
</p> </p>
<p className="text-sm text-gray-700 dark:text-gray-200"> <p className="text-sm text-gray-700 dark:text-gray-200">
Try the Fotospiel App with a test event or set up your wedding gallery right away. You can start small and 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. upgrade later at any time.
</p> </p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button asChild size="sm" className="bg-[#FFB6C1] text-white hover:bg-[#FF69B4]"> <Button asChild size="sm" className="bg-[#FFB6C1] text-white hover:bg-[#FF69B4]">
<Link href={localizedPath('/demo')}>{t('home.cta_demo')}</Link> <Link href={localizedPath('/demo')}>{t('home.cta_demo')}</Link>
</Button> </Button>
<Button <Button
asChild asChild
size="sm" size="sm"
variant="outline" variant="outline"
className="border-gray-300 text-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-50 dark:hover:bg-gray-800" className="border-gray-300 text-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-50 dark:hover:bg-gray-800"
> >
<Link href={localizedPath('/packages')}>{t('occasions.cta')}</Link> <Link href={localizedPath('/packages')}>{t('occasions.cta')}</Link>
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </motion.div>
</section> </motion.div>
</section>
</MarketingLayout> </MarketingLayout>
); );
} }
@@ -363,9 +384,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<MarketingLayout title={content.title}> <MarketingLayout title={content.title}>
<Head title={content.title} /> <Head title={content.title} />
<section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14"> <section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-5xl space-y-8"> <motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400"> <motion.div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400" variants={revealUp}>
<nav className="flex flex-wrap items-center gap-2"> <nav className="flex flex-wrap items-center gap-2">
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100"> <Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
{t('nav.home')} {t('nav.home')}
@@ -378,10 +399,11 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700"> <Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700">
{t('nav.discover_packages')} {t('nav.discover_packages')}
</Link> </Link>
</div> </motion.div>
<Card className="rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"> <motion.div variants={revealUp}>
<CardContent className="space-y-8 p-6 md:p-10"> <Card className="relative overflow-hidden rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80">
<CardContent className="space-y-8 p-6 md:p-10">
<div className="flex flex-col gap-8 md:grid md:grid-cols-[minmax(0,1.3fr)_1fr] md:items-center"> <div className="flex flex-col gap-8 md:grid md:grid-cols-[minmax(0,1.3fr)_1fr] md:items-center">
<div className="space-y-5"> <div className="space-y-5">
<Badge <Badge
@@ -438,14 +460,15 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</div> </div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </motion.div>
</motion.div>
</section> </section>
<section className="bg-white px-4 py-14 dark:bg-gray-950"> <section className="bg-white px-4 py-14 dark:bg-gray-950 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-6xl space-y-10"> <motion.div className="container mx-auto max-w-6xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> <motion.div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]" variants={revealUp}>
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-50">{t('occasions.weddings.benefits_title')}</h2> <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-50">{t('occasions.weddings.benefits_title')}</h2>
@@ -549,12 +572,15 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</CardContent> </CardContent>
</Card> </Card>
</aside> </aside>
</div> </motion.div>
<Separator className="my-8" /> <motion.div variants={revealUp}>
<Separator className="my-8" />
</motion.div>
<div className="grid gap-6 md:grid-cols-2"> <motion.div variants={revealUp}>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <div className="grid gap-6 md:grid-cols-2">
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6"> <CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Was Paare berichten Was Paare berichten
@@ -566,7 +592,7 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6"> <CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Nächster Schritt Nächster Schritt
@@ -590,8 +616,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </motion.div>
</motion.div>
</section> </section>
</MarketingLayout> </MarketingLayout>
); );
@@ -604,9 +631,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<MarketingLayout title={content.title}> <MarketingLayout title={content.title}>
<Head title={content.title} /> <Head title={content.title} />
<section className="bg-gradient-to-br from-amber-50 via-white to-pink-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14"> <section className="bg-gradient-to-br from-amber-50 via-white to-pink-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-5xl space-y-8"> <motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400"> <motion.div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400" variants={revealUp}>
<nav className="flex flex-wrap items-center gap-2"> <nav className="flex flex-wrap items-center gap-2">
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100"> <Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
{t('nav.home')} {t('nav.home')}
@@ -619,10 +646,11 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700"> <Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700">
{t('nav.discover_packages')} {t('nav.discover_packages')}
</Link> </Link>
</div> </motion.div>
<Card className="rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"> <motion.div variants={revealUp}>
<CardContent className="space-y-8 p-6 md:p-10"> <Card className="relative overflow-hidden rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80">
<CardContent className="space-y-8 p-6 md:p-10">
<div className="flex flex-col gap-8 md:grid md:grid-cols-[minmax(0,1.4fr)_1fr] md:items-center"> <div className="flex flex-col gap-8 md:grid md:grid-cols-[minmax(0,1.4fr)_1fr] md:items-center">
<div className="space-y-5"> <div className="space-y-5">
<Badge <Badge
@@ -679,16 +707,17 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
</CardContent> </div>
</Card> </CardContent>
</div> </Card>
</motion.div>
</motion.div>
</section> </section>
<section className="bg-white px-4 py-14 dark:bg-gray-950"> <section className="bg-white px-4 py-14 dark:bg-gray-950 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-6xl space-y-10"> <motion.div className="container mx-auto max-w-6xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> <motion.div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]" variants={revealUp}>
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-50">How to turn your party into a photo challenge</h2> <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-50">How to turn your party into a photo challenge</h2>
@@ -789,29 +818,32 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</li> </li>
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>
</aside> </aside>
</div> </motion.div>
<motion.div variants={revealUp}>
<Separator className="my-8" /> <Separator className="my-8" />
</motion.div>
<motion.div variants={revealUp}>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6"> <CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
What hosts say What hosts say
</p> </p>
<p className="text-sm text-gray-700 dark:text-gray-200"> <p className="text-sm text-gray-700 dark:text-gray-200">
“The kids loved the challenges and we ended up with all photos in one place instead of scrolling through “The kids loved the challenges and we ended up with all photos in one place instead of scrolling through
hundreds of chat messages.” hundreds of chat messages.”
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6"> <CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Next step Next step
</p> </p>
<p className="text-sm text-gray-700 dark:text-gray-200"> <p className="text-sm text-gray-700 dark:text-gray-200">
Create your birthday event in the Fotospiel App now and test the flow with a few friends your QR code will be 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<OccasionsProps> = ({ type }) => {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </motion.div>
</section> </motion.div>
</section>
</MarketingLayout> </MarketingLayout>
); );
} }
@@ -844,9 +877,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<MarketingLayout title={content.title}> <MarketingLayout title={content.title}>
<Head title={content.title} /> <Head title={content.title} />
<section className="bg-gradient-to-br from-amber-50 via-white to-pink-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14"> <section className="bg-gradient-to-br from-amber-50 via-white to-pink-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-5xl space-y-8"> <motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400"> <motion.div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400" variants={revealUp}>
<nav className="flex flex-wrap items-center gap-2"> <nav className="flex flex-wrap items-center gap-2">
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100"> <Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
{t('nav.home')} {t('nav.home')}
@@ -859,10 +892,11 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700"> <Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700">
{t('nav.discover_packages')} {t('nav.discover_packages')}
</Link> </Link>
</div> </motion.div>
<Card className="rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"> <motion.div variants={revealUp}>
<CardContent className="space-y-8 p-6 md:p-10"> <Card className="relative overflow-hidden rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80">
<CardContent className="space-y-8 p-6 md:p-10">
<div className="flex flex-col gap-8 md:grid md:grid-cols-[minmax(0,1.4fr)_1fr] md:items-center"> <div className="flex flex-col gap-8 md:grid md:grid-cols-[minmax(0,1.4fr)_1fr] md:items-center">
<div className="space-y-5"> <div className="space-y-5">
<Badge <Badge
@@ -920,14 +954,15 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</div> </div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </motion.div>
</motion.div>
</section> </section>
<section className="bg-white px-4 py-14 dark:bg-gray-950"> <section className="bg-white px-4 py-14 dark:bg-gray-950 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-6xl space-y-10"> <motion.div className="container mx-auto max-w-6xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> <motion.div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]" variants={revealUp}>
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-50">So wird eure Party zur Foto-Challenge</h2> <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-50">So wird eure Party zur Foto-Challenge</h2>
@@ -1030,12 +1065,15 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</CardContent> </CardContent>
</Card> </Card>
</aside> </aside>
</div> </motion.div>
<Separator className="my-8" /> <motion.div variants={revealUp}>
<Separator className="my-8" />
</motion.div>
<div className="grid gap-6 md:grid-cols-2"> <motion.div variants={revealUp}>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <div className="grid gap-6 md:grid-cols-2">
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6"> <CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Was Gastgeber:innen berichten Was Gastgeber:innen berichten
@@ -1047,7 +1085,7 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6"> <CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Nächster Schritt Nächster Schritt
@@ -1071,8 +1109,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </motion.div>
</motion.div>
</section> </section>
</MarketingLayout> </MarketingLayout>
); );
@@ -1085,9 +1124,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<MarketingLayout title={content.title}> <MarketingLayout title={content.title}>
<Head title={content.title} /> <Head title={content.title} />
<section className="bg-gradient-to-br from-sky-50 via-white to-slate-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14"> <section className="bg-gradient-to-br from-sky-50 via-white to-slate-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-5xl space-y-8"> <motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400"> <motion.div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400" variants={revealUp}>
<nav className="flex flex-wrap items-center gap-2"> <nav className="flex flex-wrap items-center gap-2">
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100"> <Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
{t('nav.home')} {t('nav.home')}
@@ -1100,10 +1139,11 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700"> <Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700">
{t('nav.discover_packages')} {t('nav.discover_packages')}
</Link> </Link>
</div> </motion.div>
<Card className="rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"> <motion.div variants={revealUp}>
<CardContent className="space-y-8 p-6 md:p-10"> <Card className="relative overflow-hidden rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80">
<CardContent className="space-y-8 p-6 md:p-10">
<div className="flex flex-col gap-8 md:grid md:grid-cols-[minmax(0,1.5fr)_1fr] md:items-center"> <div className="flex flex-col gap-8 md:grid md:grid-cols-[minmax(0,1.5fr)_1fr] md:items-center">
<div className="space-y-5"> <div className="space-y-5">
<Badge <Badge
@@ -1159,16 +1199,17 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
</CardContent> </div>
</Card> </CardContent>
</div> </Card>
</motion.div>
</motion.div>
</section> </section>
<section className="bg-white px-4 py-14 dark:bg-gray-950"> <section className="bg-white px-4 py-14 dark:bg-gray-950 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-6xl space-y-10"> <motion.div className="container mx-auto max-w-6xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> <motion.div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]" variants={revealUp}>
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Why the Fotospiel App fits your company</h2> <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Why the Fotospiel App fits your company</h2>
@@ -1271,12 +1312,15 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</CardContent> </CardContent>
</Card> </Card>
</aside> </aside>
</div> </motion.div>
<Separator className="my-8" /> <motion.div variants={revealUp}>
<Separator className="my-8" />
</motion.div>
<div className="grid gap-6 md:grid-cols-2"> <motion.div variants={revealUp}>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <div className="grid gap-6 md:grid-cols-2">
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6"> <CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
What companies say What companies say
@@ -1288,7 +1332,7 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6"> <CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Next step Next step
@@ -1312,8 +1356,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </motion.div>
</motion.div>
</section> </section>
</MarketingLayout> </MarketingLayout>
); );
@@ -1324,9 +1369,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<MarketingLayout title={content.title}> <MarketingLayout title={content.title}>
<Head title={content.title} /> <Head title={content.title} />
<section className="bg-gradient-to-br from-sky-50 via-white to-slate-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14"> <section className="bg-gradient-to-br from-sky-50 via-white to-slate-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-5xl space-y-8"> <motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400"> <motion.div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400" variants={revealUp}>
<nav className="flex flex-wrap items-center gap-2"> <nav className="flex flex-wrap items-center gap-2">
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100"> <Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
{t('nav.home')} {t('nav.home')}
@@ -1339,10 +1384,11 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700"> <Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700">
{t('nav.discover_packages')} {t('nav.discover_packages')}
</Link> </Link>
</div> </motion.div>
<Card className="rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"> <motion.div variants={revealUp}>
<CardContent className="space-y-8 p-6 md:p-10"> <Card className="relative overflow-hidden rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80">
<CardContent className="space-y-8 p-6 md:p-10">
<div className="flex flex-col gap-8 md:grid md:grid-cols-[minmax(0,1.5fr)_1fr] md:items-center"> <div className="flex flex-col gap-8 md:grid md:grid-cols-[minmax(0,1.5fr)_1fr] md:items-center">
<div className="space-y-5"> <div className="space-y-5">
<Badge <Badge
@@ -1400,14 +1446,15 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</div> </div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </motion.div>
</motion.div>
</section> </section>
<section className="bg-white px-4 py-14 dark:bg-gray-950"> <section className="bg-white px-4 py-14 dark:bg-gray-950 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-6xl space-y-10"> <motion.div className="container mx-auto max-w-6xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> <motion.div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]" variants={revealUp}>
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Warum die Fotospiel App fürs Unternehmen passt</h2> <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Warum die Fotospiel App fürs Unternehmen passt</h2>
@@ -1511,12 +1558,15 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</CardContent> </CardContent>
</Card> </Card>
</aside> </aside>
</div> </motion.div>
<Separator className="my-8" /> <motion.div variants={revealUp}>
<Separator className="my-8" />
</motion.div>
<div className="grid gap-6 md:grid-cols-2"> <motion.div variants={revealUp}>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <div className="grid gap-6 md:grid-cols-2">
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6"> <CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Was Unternehmen berichten Was Unternehmen berichten
@@ -1528,7 +1578,7 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="spacey-3 space-y-3 p-6"> <CardContent className="spacey-3 space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Nächster Schritt Nächster Schritt
@@ -1552,8 +1602,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </motion.div>
</motion.div>
</section> </section>
</MarketingLayout> </MarketingLayout>
); );
@@ -1567,9 +1618,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<MarketingLayout title={content.title}> <MarketingLayout title={content.title}>
<Head title={content.title} /> <Head title={content.title} />
<section className="bg-gradient-to-br from-violet-50 via-white to-emerald-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14"> <section className="bg-gradient-to-br from-violet-50 via-white to-emerald-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-5xl space-y-8"> <motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400"> <motion.div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400" variants={revealUp}>
<nav className="flex flex-wrap items-center gap-2"> <nav className="flex flex-wrap items-center gap-2">
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100"> <Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
{t('nav.home')} {t('nav.home')}
@@ -1582,10 +1633,11 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700"> <Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700">
{t('nav.discover_packages')} {t('nav.discover_packages')}
</Link> </Link>
</div> </motion.div>
<Card className="rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"> <motion.div variants={revealUp}>
<CardContent className="space-y-6 p-6 md:p-10"> <Card className="relative overflow-hidden rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80">
<CardContent className="space-y-6 p-6 md:p-10">
<div className="space-y-4 text-center md:text-left"> <div className="space-y-4 text-center md:text-left">
<Badge <Badge
variant="outline" variant="outline"
@@ -1613,14 +1665,15 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</Button> </Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </motion.div>
</motion.div>
</section> </section>
<section className="bg-white px-4 py-14 dark:bg-gray-950"> <section className="bg-white px-4 py-14 dark:bg-gray-950 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-5xl space-y-10"> <motion.div className="container mx-auto max-w-5xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<div className="grid gap-8 md:grid-cols-2"> <motion.div className="grid gap-8 md:grid-cols-2" variants={revealUp}>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6"> <CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
@@ -1646,8 +1699,8 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</ol> </ol>
</CardContent> </CardContent>
</Card> </Card>
</div> </motion.div>
</div> </motion.div>
</section> </section>
</MarketingLayout> </MarketingLayout>
); );
@@ -1658,9 +1711,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<MarketingLayout title={content.title}> <MarketingLayout title={content.title}>
<Head title={content.title} /> <Head title={content.title} />
<section className="bg-gradient-to-br from-violet-50 via-white to-emerald-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14"> <section className="bg-gradient-to-br from-violet-50 via-white to-emerald-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-14 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-5xl space-y-8"> <motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400"> <motion.div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400" variants={revealUp}>
<nav className="flex flex-wrap items-center gap-2"> <nav className="flex flex-wrap items-center gap-2">
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100"> <Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
{t('nav.home')} {t('nav.home')}
@@ -1673,10 +1726,11 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700"> <Link href={localizedPath('/packages')} className="text-pink-600 hover:text-pink-700">
{t('nav.discover_packages')} {t('nav.discover_packages')}
</Link> </Link>
</div> </motion.div>
<Card className="rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"> <motion.div variants={revealUp}>
<CardContent className="space-y-6 p-6 md:p-10"> <Card className="relative overflow-hidden rounded-[32px] border border-white/60 bg-white/80 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80">
<CardContent className="space-y-6 p-6 md:p-10">
<div className="space-y-4 text-center md:text-left"> <div className="space-y-4 text-center md:text-left">
<Badge <Badge
variant="outline" variant="outline"
@@ -1704,14 +1758,15 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</Button> </Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </motion.div>
</motion.div>
</section> </section>
<section className="bg-white px-4 py-14 dark:bg-gray-950"> <section className="bg-white px-4 py-14 dark:bg-gray-950 [data-slot=card]:transition-transform [data-slot=card]:duration-200 [data-slot=card]:ease-out [data-slot=card]:hover:-translate-y-1">
<div className="container mx-auto max-w-5xl space-y-10"> <motion.div className="container mx-auto max-w-5xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<div className="grid gap-8 md:grid-cols-2"> <motion.div className="grid gap-8 md:grid-cols-2" variants={revealUp}>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6"> <CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
@@ -1737,8 +1792,8 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
</ol> </ol>
</CardContent> </CardContent>
</Card> </Card>
</div> </motion.div>
</div> </motion.div>
</section> </section>
</MarketingLayout> </MarketingLayout>
); );

View File

@@ -17,6 +17,7 @@ import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useLocale } from '@/hooks/useLocale'; import { useLocale } from '@/hooks/useLocale';
import { ArrowRight, Check, Star } from 'lucide-react'; import { ArrowRight, Check, Star } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { motion, useReducedMotion } from 'framer-motion';
interface Package { interface Package {
id: number; id: number;
@@ -325,11 +326,26 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const { t: tCommon } = useTranslation('common'); const { t: tCommon } = useTranslation('common');
const { flash } = usePage<{ flash?: { error?: string } }>().props; const { flash } = usePage<{ flash?: { error?: string } }>().props;
const shouldReduceMotion = useReducedMotion();
const { const {
variant: packagesHeroVariant, variant: packagesHeroVariant,
trackClick: trackPackagesHeroClick, trackClick: trackPackagesHeroClick,
} = useCtaExperiment('packages_hero_cta'); } = 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(() => { useEffect(() => {
if (flash?.error) { if (flash?.error) {
toast.error(flash.error); toast.error(flash.error);
@@ -960,8 +976,13 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
return ( return (
<MarketingLayout title={t('packages.title')}> <MarketingLayout title={t('packages.title')}>
<section className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 px-4 py-12 md:py-16"> <section className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 px-4 py-12 md:py-16">
<div className="container mx-auto text-center space-y-6 md:space-y-8"> <motion.div
<div className="space-y-3 md:space-y-4"> className="container mx-auto text-center space-y-6 md:space-y-8"
variants={stagger}
initial="hidden"
animate="visible"
>
<motion.div className="space-y-3 md:space-y-4" variants={revealUp}>
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-gray-600 dark:text-gray-300"> <p className="text-xs font-semibold uppercase tracking-[0.35em] text-gray-600 dark:text-gray-300">
{t('packages.for_endcustomers')} · {t('packages.for_resellers')} {t('packages.for_endcustomers')} · {t('packages.for_resellers')}
</p> </p>
@@ -971,8 +992,8 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
<p className="mx-auto max-w-2xl font-sans-marketing text-base text-gray-700 dark:text-gray-200 md:text-xl"> <p className="mx-auto max-w-2xl font-sans-marketing text-base text-gray-700 dark:text-gray-200 md:text-xl">
{t('packages.hero_description')} {t('packages.hero_description')}
</p> </p>
</div> </motion.div>
<div className="flex flex-wrap items-center justify-center gap-3"> <motion.div className="flex flex-wrap items-center justify-center gap-3" variants={revealUp}>
<Button <Button
asChild asChild
size="lg" size="lg"
@@ -1011,11 +1032,11 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
{t('packages.gift_cta', 'Paket verschenken')} {t('packages.gift_cta', 'Paket verschenken')}
</Link> </Link>
</Button> </Button>
</div> </motion.div>
<p className="text-sm text-gray-600 dark:text-gray-300"> <motion.p className="text-sm text-gray-600 dark:text-gray-300" variants={revealUp}>
{t('packages.hero_secondary')} {t('packages.hero_secondary')}
</p> </motion.p>
</div> </motion.div>
</section> </section>
<section id="packages-showcase" className="px-4 py-16 md:py-20"> <section id="packages-showcase" className="px-4 py-16 md:py-20">
@@ -1045,14 +1066,16 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
> >
{orderedEndcustomerPackages.map((pkg) => ( {orderedEndcustomerPackages.map((pkg) => (
<div key={pkg.id} className="snap-start basis-[72vw] shrink-0 sm:basis-[60vw]"> <div key={pkg.id} className="snap-start basis-[72vw] shrink-0 sm:basis-[60vw]">
<PackageCard <motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
pkg={pkg} <PackageCard
variant="endcustomer" pkg={pkg}
highlight={pkg.id === highlightEndcustomerId} variant="endcustomer"
onSelect={(selected) => handleCardClick(selected, 'endcustomer')} highlight={pkg.id === highlightEndcustomerId}
className="h-full" onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
compact className="h-full"
/> compact
/>
</motion.div>
</div> </div>
))} ))}
</div> </div>
@@ -1060,18 +1083,27 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
</div> </div>
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3"> <div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
{orderedEndcustomerPackages.map((pkg) => ( {orderedEndcustomerPackages.map((pkg) => (
<PackageCard <motion.div
key={pkg.id} key={pkg.id}
pkg={pkg} variants={revealUp}
variant="endcustomer" initial="hidden"
highlight={pkg.id === highlightEndcustomerId} whileInView="visible"
onSelect={(selected) => handleCardClick(selected, 'endcustomer')} viewport={viewportOnce}
className="h-full" >
compact <PackageCard
/> pkg={pkg}
variant="endcustomer"
highlight={pkg.id === highlightEndcustomerId}
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
className="h-full"
compact
/>
</motion.div>
))} ))}
</div> </div>
<PackageComparison packages={orderedEndcustomerPackages} variant="endcustomer" /> <motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<PackageComparison packages={orderedEndcustomerPackages} variant="endcustomer" />
</motion.div>
</TabsContent> </TabsContent>
<TabsContent value="reseller" className="space-y-8"> <TabsContent value="reseller" className="space-y-8">
@@ -1086,14 +1118,16 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
> >
{orderedResellerPackages.map((pkg) => ( {orderedResellerPackages.map((pkg) => (
<div key={pkg.id} className="snap-start basis-[72vw] shrink-0 sm:basis-[60vw]"> <div key={pkg.id} className="snap-start basis-[72vw] shrink-0 sm:basis-[60vw]">
<PackageCard <motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
pkg={pkg} <PackageCard
variant="reseller" pkg={pkg}
highlight={pkg.id === highlightResellerId} variant="reseller"
onSelect={(selected) => handleCardClick(selected, 'reseller')} highlight={pkg.id === highlightResellerId}
className="h-full" onSelect={(selected) => handleCardClick(selected, 'reseller')}
compact className="h-full"
/> compact
/>
</motion.div>
</div> </div>
))} ))}
</div> </div>
@@ -1101,18 +1135,27 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
</div> </div>
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3"> <div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
{orderedResellerPackages.map((pkg) => ( {orderedResellerPackages.map((pkg) => (
<PackageCard <motion.div
key={pkg.id} key={pkg.id}
pkg={pkg} variants={revealUp}
variant="reseller" initial="hidden"
highlight={pkg.id === highlightResellerId} whileInView="visible"
onSelect={(selected) => handleCardClick(selected, 'reseller')} viewport={viewportOnce}
className="h-full" >
compact <PackageCard
/> pkg={pkg}
variant="reseller"
highlight={pkg.id === highlightResellerId}
onSelect={(selected) => handleCardClick(selected, 'reseller')}
className="h-full"
compact
/>
</motion.div>
))} ))}
</div> </div>
<PackageComparison packages={orderedResellerPackages} variant="reseller" serviceTierNames={serviceTierNames} /> <motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<PackageComparison packages={orderedResellerPackages} variant="reseller" serviceTierNames={serviceTierNames} />
</motion.div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
@@ -1131,14 +1174,16 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
{ title: t('packages.faq_reseller'), body: t('packages.faq_reseller_desc') }, { title: t('packages.faq_reseller'), body: t('packages.faq_reseller_desc') },
{ title: t('packages.faq_payment'), body: t('packages.faq_payment_desc') }, { title: t('packages.faq_payment'), body: t('packages.faq_payment_desc') },
].map((item) => ( ].map((item) => (
<Card key={item.title} className="h-full border border-gray-200/70 dark:border-gray-800/70"> <motion.div key={item.title} variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<CardHeader> <Card className="h-full border border-gray-200/70 dark:border-gray-800/70">
<CardTitle className="text-xl font-display">{item.title}</CardTitle> <CardHeader>
</CardHeader> <CardTitle className="text-xl font-display">{item.title}</CardTitle>
<CardContent> </CardHeader>
<p className="text-sm text-gray-600 dark:text-gray-300">{item.body}</p> <CardContent>
</CardContent> <p className="text-sm text-gray-600 dark:text-gray-300">{item.body}</p>
</Card> </CardContent>
</Card>
</motion.div>
))} ))}
</div> </div>
</div> </div>

View File

@@ -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 }) => <a href={href}>{children}</a>,
usePage: () => ({ props: { supportedLocales: ['de', 'en'] } }),
}));
vi.mock('@/layouts/mainWebsite', () => ({
default: ({ children, title }: { children: React.ReactNode; title: string }) => (
<div>
<h1>{title}</h1>
{children}
</div>
),
}));
vi.mock('@/hooks/useLocalizedRoutes', () => ({
useLocalizedRoutes: () => ({
localizedPath: (path: string) => path,
}),
}));
import Blog from '../Blog';
describe('Blog', () => {
it('renders the hero title', () => {
render(
<Blog
posts={{
data: [
{
id: 1,
slug: 'hello',
title: 'Hello',
excerpt: 'Excerpt',
published_at: '2024-01-01',
author: 'Team',
},
],
links: [],
current_page: 1,
last_page: 1,
}}
/>
);
expect(screen.getByText('blog.hero_title')).toBeInTheDocument();
});
});

View File

@@ -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 }) => <a href={href}>{children}</a>,
}));
vi.mock('@/layouts/mainWebsite', () => ({
default: ({ children, title }: { children: React.ReactNode; title: string }) => (
<div>
<h1>{title}</h1>
{children}
</div>
),
}));
vi.mock('@/hooks/useLocalizedRoutes', () => ({
useLocalizedRoutes: () => ({
localizedPath: (path: string) => path,
}),
}));
import BlogShow from '../BlogShow';
describe('BlogShow', () => {
it('renders the article title', () => {
render(
<BlogShow
post={{
id: 1,
title: 'Hello World',
excerpt: 'Excerpt',
excerpt_html: '<p>Excerpt</p>',
content: 'Content',
content_html: '<p>Content</p>',
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);
});
});

View File

@@ -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 }) => <a href={href}>{children}</a>,
}));
vi.mock('@/layouts/mainWebsite', () => ({
default: ({ children, title }: { children: React.ReactNode; title: string }) => (
<div>
<h1>{title}</h1>
{children}
</div>
),
}));
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 }) => <div>{children}</div>,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
import DemoPage from '../Demo';
describe('DemoPage', () => {
it('renders the demo headline', () => {
render(<DemoPage demoToken="demo" />);
expect(screen.getAllByText('Demo Page').length).toBeGreaterThan(0);
});
});

View File

@@ -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 }) => <a href={href}>{children}</a>,
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 }) => (
<div>
<h1>{title}</h1>
{children}
</div>
),
}));
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(
<Home
packages={[
{ id: 1, name: 'Starter', description: 'Desc', price: 29 },
{ id: 2, name: 'Standard', description: 'Desc', price: 59 },
]}
/>
);
expect(screen.getByText('home.hero_title')).toBeInTheDocument();
});
});

View File

@@ -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 }) => <a href={href}>{children}</a>,
}));
vi.mock('@/layouts/mainWebsite', () => ({
default: ({ children, title }: { children: React.ReactNode; title: string }) => (
<div>
<h1>{title}</h1>
{children}
</div>
),
}));
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(<HowItWorks />);
expect(screen.getAllByText('So funktioniert die Fotospiel App').length).toBeGreaterThan(0);
});
});

View File

@@ -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 }) => <a href={href}>{children}</a>,
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 }) => (
<div>
<h1>{title}</h1>
{children}
</div>
),
}));
vi.mock('@/hooks/useLocalizedRoutes', () => ({
useLocalizedRoutes: () => ({
localizedPath: (path: string) => path,
}),
}));
import Kontakt from '../Kontakt';
describe('Kontakt', () => {
it('renders the page title', () => {
render(<Kontakt />);
expect(screen.getAllByText('kontakt.title').length).toBeGreaterThan(0);
});
});

View File

@@ -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 }) => <a href={href}>{children}</a>,
}));
vi.mock('@/layouts/mainWebsite', () => ({
default: ({ children, title }: { children: React.ReactNode; title: string }) => (
<div>
<h1>{title}</h1>
{children}
</div>
),
}));
vi.mock('@/hooks/useLocalizedRoutes', () => ({
useLocalizedRoutes: () => ({
localizedPath: (path: string) => path,
}),
}));
import Occasions from '../Occasions';
describe('Occasions', () => {
it('renders the wedding hero content', () => {
render(<Occasions type="hochzeit" />);
expect(screen.getAllByText('occasions.weddings.title').length).toBeGreaterThan(0);
});
});

View File

@@ -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 }) => <a href={href}>{children}</a>,
usePage: () => ({ props: { flash: {} } }),
}));
vi.mock('@/layouts/mainWebsite', () => ({
default: ({ children, title }: { children: React.ReactNode; title: string }) => (
<div>
<h1>{title}</h1>
{children}
</div>
),
}));
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(
<Packages
endcustomerPackages={[
{
id: 1,
name: 'Starter',
slug: 'starter',
description: 'Desc',
description_breakdown: [],
price: 29,
events: 1,
features: [],
limits: { max_photos: 600, max_guests: 100, gallery_days: 180 },
branding_allowed: false,
watermark_allowed: false,
},
{
id: 2,
name: 'Standard',
slug: 'standard',
description: 'Desc',
description_breakdown: [],
price: 59,
events: 1,
features: ['no_watermark'],
limits: { max_photos: 4000, max_guests: 250, gallery_days: 365 },
branding_allowed: true,
watermark_allowed: true,
},
]}
resellerPackages={[
{
id: 3,
name: 'Agentur',
slug: 'agency',
description: 'Desc',
description_breakdown: [],
price: 199,
events: null,
features: ['reseller_dashboard'],
limits: { max_events_per_year: 10 },
included_package_slug: 'standard',
branding_allowed: true,
watermark_allowed: true,
},
]}
/>
);
expect(screen.getByText('packages.hero_title')).toBeInTheDocument();
});
});