Add marketing motion reveals to blog and occasions
This commit is contained in:
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
interface PostSummary {
|
||||
id: number;
|
||||
@@ -56,12 +57,26 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
||||
);
|
||||
const { t, i18n } = useTranslation('marketing');
|
||||
const locale = i18n.language || 'de';
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const articles = posts?.data ?? [];
|
||||
const isLandingLayout = posts.current_page === 1;
|
||||
const featuredPost = isLandingLayout ? articles[0] : null;
|
||||
const gridPosts = isLandingLayout ? articles.slice(1, 4) : [];
|
||||
const listPosts = isLandingLayout ? [] : articles;
|
||||
const dateLocale = locale === 'en' ? 'en-US' : 'de-DE';
|
||||
const viewportOnce = { once: true, amount: 0.25 };
|
||||
const revealUp = {
|
||||
hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 18 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
|
||||
},
|
||||
};
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.12 } },
|
||||
};
|
||||
|
||||
const buildArticleHref = React.useCallback(
|
||||
(slug?: string | null) => {
|
||||
@@ -194,8 +209,20 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div className="grid gap-8 lg:grid-cols-12 lg:items-center">
|
||||
<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">
|
||||
<motion.div
|
||||
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">
|
||||
{t('blog.posts_title')}
|
||||
</Badge>
|
||||
@@ -213,9 +240,15 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
||||
{t('blog.read_more')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="order-1 lg:order-2 lg:col-span-6">
|
||||
<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">
|
||||
</motion.div>
|
||||
<motion.div
|
||||
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 && (
|
||||
<img
|
||||
src={featuredPost.featured_image}
|
||||
@@ -229,82 +262,100 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{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) => (
|
||||
<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">
|
||||
{post.featured_image && (
|
||||
<div className="aspect-video">
|
||||
<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">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-50">
|
||||
{post.title || 'Untitled'}
|
||||
</h3>
|
||||
<MarkdownPreview
|
||||
html={post.excerpt_html}
|
||||
fallback={post.excerpt}
|
||||
className="text-sm leading-relaxed text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{renderPostMeta(post)}
|
||||
<Button asChild variant="ghost" className="justify-start p-0 text-pink-600 hover:text-pink-700">
|
||||
<Link href={buildArticleHref(post.slug)}>
|
||||
{t('blog.read_more')}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<motion.div key={post.id} variants={revealUp}>
|
||||
<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">
|
||||
{post.featured_image && (
|
||||
<div className="aspect-video">
|
||||
<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">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-50">
|
||||
{post.title || 'Untitled'}
|
||||
</h3>
|
||||
<MarkdownPreview
|
||||
html={post.excerpt_html}
|
||||
fallback={post.excerpt}
|
||||
className="text-sm leading-relaxed text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{renderPostMeta(post)}
|
||||
<Button asChild variant="ghost" className="justify-start p-0 text-pink-600 hover:text-pink-700">
|
||||
<Link href={buildArticleHref(post.slug)}>
|
||||
{t('blog.read_more')}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderListView = () => (
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={stagger}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
>
|
||||
{listPosts.map((post) => (
|
||||
<Card key={post.id} className="overflow-hidden border-gray-200 shadow-sm hover:shadow-lg dark:border-gray-800">
|
||||
<div className="grid gap-0 md:grid-cols-3">
|
||||
{post.featured_image && (
|
||||
<img
|
||||
src={post.featured_image}
|
||||
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"
|
||||
<motion.div key={post.id} variants={revealUp}>
|
||||
<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">
|
||||
<div className="grid gap-0 md:grid-cols-3">
|
||||
{post.featured_image && (
|
||||
<img
|
||||
src={post.featured_image}
|
||||
alt={post.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
<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">
|
||||
<Link href={buildArticleHref(post.slug)}>
|
||||
{t('blog.read_more')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
{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 (
|
||||
@@ -312,29 +363,46 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
||||
<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">
|
||||
<div className="container mx-auto max-w-3xl space-y-5 text-center">
|
||||
<Badge variant="outline" className="border-pink-200 text-pink-600 dark:border-pink-500/50 dark:text-pink-200">
|
||||
Fotospiel Blog
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-50 md:text-4xl">{t('blog.hero_title')}</h1>
|
||||
<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
|
||||
className="container mx-auto max-w-3xl space-y-5 text-center"
|
||||
variants={stagger}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<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">
|
||||
<Link href={localizedPath('/packages')}>{t('home.cta_explore')}</Link>
|
||||
</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">
|
||||
<Link href="#articles">{t('blog.hero_cta')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</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="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>
|
||||
<Separator className="mx-auto mt-4 w-24" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{isLandingLayout ? renderLandingGrid() : renderListView()}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
interface AdjacentPost {
|
||||
slug: string;
|
||||
@@ -58,6 +59,20 @@ const BlogShow: React.FC<Props> = ({ post }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const locale = i18n.language || 'de';
|
||||
const dateLocale = locale === 'en' ? 'en-US' : 'de-DE';
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const viewportOnce = { once: true, amount: 0.25 };
|
||||
const revealUp = {
|
||||
hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 18 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
|
||||
},
|
||||
};
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.12 } },
|
||||
};
|
||||
|
||||
const formattedDate = React.useMemo(() => {
|
||||
try {
|
||||
@@ -121,9 +136,9 @@ const BlogShow: React.FC<Props> = ({ post }) => {
|
||||
<MarketingLayout 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">
|
||||
<div className="container mx-auto max-w-5xl space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-5xl space-y-5" variants={stagger} initial="hidden" animate="visible">
|
||||
<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">
|
||||
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
|
||||
{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">
|
||||
{t('back_to_blog')}
|
||||
</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">
|
||||
<CardContent className="space-y-6 p-6 md:p-8">
|
||||
<div className="flex flex-col gap-6 md:grid md:grid-cols-[minmax(0,1fr)_240px] md:items-center md:gap-8">
|
||||
<div className="space-y-4 text-left">
|
||||
<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">
|
||||
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>
|
||||
<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">
|
||||
<motion.div variants={revealUp}>
|
||||
<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">
|
||||
<CardContent className="space-y-6 p-6 md:p-8">
|
||||
<div className="flex flex-col gap-6 md:grid md:grid-cols-[minmax(0,1fr)_240px] md:items-center md:gap-8">
|
||||
<div className="space-y-4 text-left">
|
||||
<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">
|
||||
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>
|
||||
)}
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white px-4 py-12 dark:bg-gray-950">
|
||||
<div className="container mx-auto max-w-6xl gap-10 lg:grid lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<article className="rounded-[32px] border border-gray-100 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<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">
|
||||
<motion.div
|
||||
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
|
||||
className="prose prose-lg prose-slate max-w-none
|
||||
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"
|
||||
dangerouslySetInnerHTML={{ __html: post.content_html }}
|
||||
/>
|
||||
</article>
|
||||
</motion.article>
|
||||
|
||||
<aside className="mt-10 space-y-6 lg:mt-0 lg:sticky lg:top-24">
|
||||
<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('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>
|
||||
<motion.aside className="mt-10 space-y-6 lg:mt-0 lg:sticky lg:top-24" variants={stagger}>
|
||||
<motion.div variants={revealUp}>
|
||||
<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('share_title')}
|
||||
{t('summary_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>
|
||||
{canUseNativeShare && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-700 hover:text-pink-600 dark:text-gray-200"
|
||||
onClick={() => navigator.share?.({ title: post.title, url: shareUrl })}
|
||||
>
|
||||
{t('share_native')}
|
||||
</Button>
|
||||
<MarkdownPreview
|
||||
html={post.excerpt_html}
|
||||
fallback={post.excerpt}
|
||||
className="text-base leading-relaxed text-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
</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">
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</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-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>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
{canUseNativeShare && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-700 hover:text-pink-600 dark:text-gray-200"
|
||||
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>
|
||||
|
||||
{(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">
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<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">
|
||||
<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">
|
||||
{post.previous_post && (
|
||||
<Card className="border-none bg-gradient-to-br from-pink-500 to-pink-400 text-white shadow-lg">
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-white/80">
|
||||
{t('previous_post')}
|
||||
</p>
|
||||
<h3 className="text-2xl font-semibold">{post.previous_post.title}</h3>
|
||||
<MarkdownPreview
|
||||
html={post.previous_post.excerpt_html}
|
||||
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)}>
|
||||
{t('read_story')}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<motion.div variants={revealUp}>
|
||||
<Card className="border-none bg-gradient-to-br from-pink-500 to-pink-400 text-white shadow-lg">
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-white/80">
|
||||
{t('previous_post')}
|
||||
</p>
|
||||
<h3 className="text-2xl font-semibold">{post.previous_post.title}</h3>
|
||||
<MarkdownPreview
|
||||
html={post.previous_post.excerpt_html}
|
||||
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)}>
|
||||
{t('read_story')}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{post.next_post && (
|
||||
<Card className="border-none bg-gradient-to-br from-amber-400 to-pink-400 text-gray-900 shadow-lg">
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-800/80">
|
||||
{t('next_post')}
|
||||
</p>
|
||||
<h3 className="text-2xl font-semibold">{post.next_post.title}</h3>
|
||||
<MarkdownPreview
|
||||
html={post.next_post.excerpt_html}
|
||||
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)}>
|
||||
{t('read_story')}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<motion.div variants={revealUp}>
|
||||
<Card className="border-none bg-gradient-to-br from-amber-400 to-pink-400 text-gray-900 shadow-lg">
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-800/80">
|
||||
{t('next_post')}
|
||||
</p>
|
||||
<h3 className="text-2xl font-semibold">{post.next_post.title}</h3>
|
||||
<MarkdownPreview
|
||||
html={post.next_post.excerpt_html}
|
||||
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)}>
|
||||
{t('read_story')}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
)}
|
||||
</MarketingLayout>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Head, Link } from '@inertiajs/react';
|
||||
import { CheckCircle2, Sparkles } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
type DemoFeature = { title: string; description: string };
|
||||
|
||||
@@ -21,6 +22,7 @@ const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
|
||||
const { t } = useTranslation('marketing');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const locale = useLocale();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const embedUrl = demoToken ? `/e/${demoToken}` : '/e/demo?demo=1';
|
||||
const [isDemoOpen, setIsDemoOpen] = React.useState(false);
|
||||
const demoOpenLabel = t('labels.demoOpenOverlay', locale === 'en' ? 'Open demo overlay' : 'Demo im Overlay öffnen');
|
||||
@@ -38,6 +40,25 @@ const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
|
||||
const handleOpenDemo = (): void => {
|
||||
setIsDemoOpen(true);
|
||||
};
|
||||
const frameVariants = shouldReduceMotion
|
||||
? { rest: {}, hover: {} }
|
||||
: {
|
||||
rest: { y: 0, scale: 1, rotateX: 0, rotateY: 0 },
|
||||
hover: {
|
||||
y: -6,
|
||||
scale: 1.01,
|
||||
rotateX: -2,
|
||||
rotateY: 3,
|
||||
transition: { type: 'spring', stiffness: 180, damping: 20 },
|
||||
},
|
||||
};
|
||||
const shineVariants = shouldReduceMotion
|
||||
? { rest: {}, hover: {} }
|
||||
: {
|
||||
rest: { x: '-120%', opacity: 0 },
|
||||
hover: { x: '120%', opacity: 0.8, transition: { duration: 0.8, ease: 'easeOut' } },
|
||||
};
|
||||
const featureHover = shouldReduceMotion ? {} : { y: -4, scale: 1.01 };
|
||||
|
||||
return (
|
||||
<MarketingLayout title={demo.title}>
|
||||
@@ -74,28 +95,46 @@ const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
|
||||
<div className="flex-1">
|
||||
{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="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 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>
|
||||
<div className="mx-auto w-[min(92vw,calc(75dvh*10/14))] aspect-[10/14]" style={{ perspective: 1200 }}>
|
||||
<motion.div
|
||||
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"
|
||||
initial="rest"
|
||||
animate="rest"
|
||||
whileHover="hover"
|
||||
variants={frameVariants}
|
||||
style={{ transformStyle: 'preserve-3d' }}
|
||||
>
|
||||
<motion.span
|
||||
className="pointer-events-none absolute inset-0 rounded-[2.5rem]"
|
||||
variants={shineVariants}
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(120deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 50%, rgba(255,255,255,0) 100%)',
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
/>
|
||||
<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 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 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>
|
||||
@@ -143,17 +182,19 @@ const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{demoFeatures.map((feature) => (
|
||||
<Card key={feature.title} className="border-gray-100 shadow-sm dark:border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Sparkles className="h-5 w-5 text-pink-500" aria-hidden />
|
||||
{feature.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-sm text-gray-600 dark:text-gray-300">{feature.description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<motion.div key={feature.title} whileHover={featureHover} transition={{ duration: 0.2, ease: 'easeOut' }}>
|
||||
<Card className="border-gray-100 shadow-sm dark:border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Sparkles className="h-5 w-5 text-pink-500" aria-hidden />
|
||||
{feature.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-sm text-gray-600 dark:text-gray-300">{feature.description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ArrowRight, Camera, QrCode, ShieldCheck, Sparkles, Smartphone, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
interface Package {
|
||||
id: number;
|
||||
@@ -36,6 +37,7 @@ const Home: React.FC<Props> = ({ packages }) => {
|
||||
trackClick: trackHeroCtaClick,
|
||||
} = useCtaExperiment('home_hero_cta');
|
||||
const { flash } = usePage<{ flash?: { success?: string } }>().props;
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const { data, setData, post, processing, errors, reset } = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
@@ -43,6 +45,52 @@ const Home: React.FC<Props> = ({ packages }) => {
|
||||
nickname: '',
|
||||
});
|
||||
|
||||
const viewportOnce = { once: true, amount: 0.25 };
|
||||
const revealUp = {
|
||||
hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 18 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
|
||||
},
|
||||
};
|
||||
const revealFadeIn = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { duration: 0.5, ease: 'easeOut' } },
|
||||
};
|
||||
const revealFade = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { duration: 0.5, ease: 'easeOut' } },
|
||||
};
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.12 } },
|
||||
};
|
||||
const heroCardVariants = shouldReduceMotion
|
||||
? { rest: {}, hover: {} }
|
||||
: {
|
||||
rest: { y: 0, scale: 1, rotateX: 0, rotateY: 0 },
|
||||
hover: {
|
||||
y: -8,
|
||||
scale: 1.01,
|
||||
rotateX: -2,
|
||||
rotateY: 3,
|
||||
transition: { type: 'spring', stiffness: 180, damping: 20 },
|
||||
},
|
||||
};
|
||||
const heroGlowVariants = shouldReduceMotion
|
||||
? { rest: {}, hover: {} }
|
||||
: {
|
||||
rest: { opacity: 0.4, scale: 1 },
|
||||
hover: { opacity: 0.7, scale: 1.03, transition: { duration: 0.35, ease: 'easeOut' } },
|
||||
};
|
||||
const heroShineVariants = shouldReduceMotion
|
||||
? { rest: {}, hover: {} }
|
||||
: {
|
||||
rest: { x: '-120%', opacity: 0 },
|
||||
hover: { x: '120%', opacity: 0.8, transition: { duration: 0.8, ease: 'easeOut' } },
|
||||
};
|
||||
|
||||
const heroBulletsRaw = t('home.hero_bullets', { returnObjects: true });
|
||||
const heroBullets = Array.isArray(heroBulletsRaw) ? (heroBulletsRaw as string[]) : [];
|
||||
|
||||
@@ -109,34 +157,53 @@ const Home: React.FC<Props> = ({ packages }) => {
|
||||
|
||||
<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="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">
|
||||
<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]">
|
||||
{t('home.hero_tagline')}
|
||||
</Badge>
|
||||
<h1 className="font-display text-4xl font-bold leading-tight md:text-5xl lg:text-6xl">
|
||||
{t('home.hero_title')}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-700 dark:text-gray-200 md:text-xl">
|
||||
{t('home.hero_description')}
|
||||
</p>
|
||||
<motion.div variants={revealFadeIn}>
|
||||
<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]">
|
||||
{t('home.hero_tagline')}
|
||||
</Badge>
|
||||
</motion.div>
|
||||
<motion.div>
|
||||
<motion.h1 className="font-display text-4xl font-bold leading-tight md:text-5xl lg:text-6xl" variants={revealFadeIn}>
|
||||
{t('home.hero_title')}
|
||||
</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 && (
|
||||
<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">
|
||||
{heroBullets.map((item, index) => {
|
||||
const Icon = heroBulletIcons[index % heroBulletIcons.length] ?? Sparkles;
|
||||
return (
|
||||
<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">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<span className="flex-1 text-base">{item}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<motion.div>
|
||||
<motion.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"
|
||||
variants={stagger}
|
||||
>
|
||||
{heroBullets.map((item, index) => {
|
||||
const Icon = heroBulletIcons[index % heroBulletIcons.length] ?? Sparkles;
|
||||
return (
|
||||
<motion.li key={`hero-bullet-${index}`} className="flex items-start gap-3" variants={revealFadeIn}>
|
||||
<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>
|
||||
<span className="flex-1 text-base">{item}</span>
|
||||
</motion.li>
|
||||
);
|
||||
})}
|
||||
</motion.ul>
|
||||
</motion.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
|
||||
asChild
|
||||
size="lg"
|
||||
@@ -193,16 +260,45 @@ const Home: React.FC<Props> = ({ packages }) => {
|
||||
>
|
||||
{heroTertiaryLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full max-w-xl md:w-1/2">
|
||||
<div className="absolute inset-0 rounded-3xl bg-white/40 blur-xl" aria-hidden />
|
||||
<img
|
||||
src="/joyous_wedding_guests_posing.jpg"
|
||||
alt={t('home.hero_image_alt')}
|
||||
className="relative w-full rounded-[32px] border border-white/60 shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="relative w-full max-w-xl md:w-1/2"
|
||||
variants={revealFade}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
style={{ perspective: 1200 }}
|
||||
>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -216,20 +312,25 @@ const Home: React.FC<Props> = ({ packages }) => {
|
||||
</div>
|
||||
<div className="mt-12 grid gap-6 md:grid-cols-3">
|
||||
{howSteps.map(({ icon: Icon, title, description }, index) => (
|
||||
<Card
|
||||
<motion.div
|
||||
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">
|
||||
<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">
|
||||
<Icon className="h-6 w-6" aria-hidden />
|
||||
</span>
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
<CardDescription className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<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">
|
||||
<CardHeader className="flex flex-col gap-4">
|
||||
<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">
|
||||
<Icon className="h-6 w-6" aria-hidden />
|
||||
</span>
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
<CardDescription className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,36 +339,52 @@ const Home: React.FC<Props> = ({ packages }) => {
|
||||
<section className="bg-slate-950 py-20 px-4 text-white">
|
||||
<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="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">
|
||||
{t('home.demo_title')}
|
||||
</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')}
|
||||
</h2>
|
||||
<p className="text-sm text-white/75">{t('home.demo_hint')}</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
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"
|
||||
>
|
||||
<Link
|
||||
href={localizedPath('/demo')}
|
||||
onClick={() =>
|
||||
trackEvent({
|
||||
category: 'marketing_home',
|
||||
action: 'demo_section_cta',
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-2"
|
||||
</motion.h2>
|
||||
<motion.p className="text-sm text-white/75" variants={revealUp}>
|
||||
{t('home.demo_hint')}
|
||||
</motion.p>
|
||||
<motion.div variants={revealUp}>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
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"
|
||||
>
|
||||
<span>{t('home.demo_cta')}</span>
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative mx-auto w-full max-w-sm">
|
||||
<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 />
|
||||
<Link
|
||||
href={localizedPath('/demo')}
|
||||
onClick={() =>
|
||||
trackEvent({
|
||||
category: 'marketing_home',
|
||||
action: 'demo_section_cta',
|
||||
})
|
||||
}
|
||||
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="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 />
|
||||
@@ -286,7 +403,7 @@ const Home: React.FC<Props> = ({ packages }) => {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -298,17 +415,22 @@ const Home: React.FC<Props> = ({ packages }) => {
|
||||
</div>
|
||||
<div className="mt-12 grid gap-6 md:grid-cols-3">
|
||||
{features.map((feature, index) => (
|
||||
<Card
|
||||
<motion.div
|
||||
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>
|
||||
<CardTitle className="text-xl">{feature.title}</CardTitle>
|
||||
<CardDescription className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
{feature.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<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">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">{feature.title}</CardTitle>
|
||||
<CardDescription className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
{feature.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</motion.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">
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<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">
|
||||
<CardHeader className="flex flex-col gap-4">
|
||||
<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">
|
||||
{t('home.occasions_title')}
|
||||
</Badge>
|
||||
<CardTitle className="text-2xl">{t('home.occasions_description')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||
{occasionLinks.map(({ key, href }) => (
|
||||
<Link
|
||||
key={key}
|
||||
href={href}
|
||||
onClick={() =>
|
||||
trackEvent({
|
||||
category: 'marketing_home',
|
||||
action: 'occasion_tile_click',
|
||||
name: key,
|
||||
})
|
||||
}
|
||||
className="group flex items-center gap-3 rounded-xl border border-rose-100/60 bg-white/80 px-4 py-3 text-sm font-semibold text-rose-600 shadow-sm shadow-rose-100/50 transition hover:bg-rose-50 hover:text-rose-700 dark:border-rose-500/30 dark:bg-gray-900/70 dark:text-rose-200 dark:hover:bg-rose-500/10"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4 -translate-x-1 transition group-hover:translate-x-0" aria-hidden />
|
||||
<span>{t(`home.occasions.${key}`)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<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">
|
||||
<CardHeader className="flex flex-col gap-4">
|
||||
<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">
|
||||
{t('home.occasions_title')}
|
||||
</Badge>
|
||||
<CardTitle className="text-2xl">{t('home.occasions_description')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||
{occasionLinks.map(({ key, href }) => (
|
||||
<Link
|
||||
key={key}
|
||||
href={href}
|
||||
onClick={() =>
|
||||
trackEvent({
|
||||
category: 'marketing_home',
|
||||
action: 'occasion_tile_click',
|
||||
name: key,
|
||||
})
|
||||
}
|
||||
className="group flex items-center gap-3 rounded-xl border border-rose-100/60 bg-white/80 px-4 py-3 text-sm font-semibold text-rose-600 shadow-sm shadow-rose-100/50 transition hover:bg-rose-50 hover:text-rose-700 dark:border-rose-500/30 dark:bg-gray-900/70 dark:text-rose-200 dark:hover:bg-rose-500/10"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4 -translate-x-1 transition group-hover:translate-x-0" aria-hidden />
|
||||
<span>{t(`home.occasions.${key}`)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</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">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">{t('home.blog_teaser_title')}</CardTitle>
|
||||
<CardDescription className="text-sm leading-relaxed text-muted-foreground">
|
||||
{t('home.blog_teaser_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="px-6 pb-6">
|
||||
<Button
|
||||
asChild
|
||||
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"
|
||||
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<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">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">{t('home.blog_teaser_title')}</CardTitle>
|
||||
<CardDescription className="text-sm leading-relaxed text-muted-foreground">
|
||||
{t('home.blog_teaser_description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="px-6 pb-6">
|
||||
<Button
|
||||
asChild
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -408,40 +534,45 @@ const Home: React.FC<Props> = ({ packages }) => {
|
||||
</div>
|
||||
<div className="mt-10 grid gap-8 md:grid-cols-2">
|
||||
{packages.slice(0, 2).map((pkg) => (
|
||||
<Card
|
||||
<motion.div
|
||||
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">
|
||||
<CardTitle className="text-2xl">{pkg.name}</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{pkg.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
<p className="text-3xl font-bold text-rose-500">
|
||||
{pkg.price} {t('currency.euro')}
|
||||
</p>
|
||||
<Button
|
||||
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,
|
||||
})
|
||||
}
|
||||
<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">
|
||||
<CardHeader className="gap-4">
|
||||
<CardTitle className="text-2xl">{pkg.name}</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{pkg.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
<p className="text-3xl font-bold text-rose-500">
|
||||
{pkg.price} {t('currency.euro')}
|
||||
</p>
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
{t('home.view_details')}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { CheckCircle2, Images, Sparkles, Users } from 'lucide-react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
type HeroStat = { value: string; label: string };
|
||||
type ExperienceStep = { title: string; description: string };
|
||||
@@ -42,6 +43,21 @@ const HowItWorks: React.FC = () => {
|
||||
const { t, ready } = useTranslation('marketing');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const locale = useLocale();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const viewportOnce = { once: true, amount: 0.25 };
|
||||
const revealUp = {
|
||||
hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 18 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
|
||||
},
|
||||
};
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.12 } },
|
||||
};
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
@@ -139,17 +155,19 @@ const HowItWorks: React.FC = () => {
|
||||
<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="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">
|
||||
<Badge variant="outline" className="border-pink-400 text-pink-600 dark:border-pink-500 dark:text-pink-300">
|
||||
Fotospiel Flow
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-50 md:text-5xl">
|
||||
<motion.div className="flex-1 space-y-6" variants={stagger} initial="hidden" animate="visible">
|
||||
<motion.div variants={revealUp}>
|
||||
<Badge variant="outline" className="border-pink-400 text-pink-600 dark:border-pink-500 dark:text-pink-300">
|
||||
Fotospiel Flow
|
||||
</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}
|
||||
</h1>
|
||||
<p className="max-w-2xl text-lg text-gray-600 dark:text-gray-300">
|
||||
</motion.h1>
|
||||
<motion.p className="max-w-2xl text-lg text-gray-600 dark:text-gray-300" variants={revealUp}>
|
||||
{hero.subtitle}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
</motion.p>
|
||||
<motion.div className="flex flex-wrap items-center gap-3" variants={revealUp}>
|
||||
<Button asChild size="lg" className="bg-pink-500 hover:bg-pink-600">
|
||||
<Link href={localizedPath('/packages')}>
|
||||
{hero.primaryCta}
|
||||
@@ -170,23 +188,31 @@ const HowItWorks: React.FC = () => {
|
||||
{t('packages.gift_cta', 'Paket verschenken')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<div className="flex-1">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{heroStats.map((stat) => (
|
||||
<Card key={stat.label} className="border-pink-100/70 shadow-none dark:border-pink-900/40">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-2xl font-semibold text-pink-600 dark:text-pink-300">
|
||||
{stat.value}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{stat.label}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
variants={revealUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
>
|
||||
<Card className="border-pink-100/70 shadow-none dark:border-pink-900/40">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-2xl font-semibold text-pink-600 dark:text-pink-300">
|
||||
{stat.value}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{stat.label}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,10 +231,14 @@ const HowItWorks: React.FC = () => {
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<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 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>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -226,19 +256,27 @@ const HowItWorks: React.FC = () => {
|
||||
</div>
|
||||
<div className="mx-auto mt-10 grid max-w-6xl gap-6 md:grid-cols-2">
|
||||
{pillars.map((pillar) => (
|
||||
<Card key={pillar.title} className="border-gray-100 shadow-sm dark:border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3 text-left text-xl">
|
||||
<Sparkles className="h-5 w-5 text-pink-500" aria-hidden />
|
||||
{pillar.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-left text-gray-600 dark:text-gray-300">
|
||||
{pillar.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<motion.div
|
||||
key={pillar.title}
|
||||
variants={revealUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
>
|
||||
<Card className="border-gray-100 shadow-sm dark:border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3 text-left text-xl">
|
||||
<Sparkles className="h-5 w-5 text-pink-500" aria-hidden />
|
||||
{pillar.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-left text-gray-600 dark:text-gray-300">
|
||||
{pillar.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -312,48 +350,50 @@ const HowItWorks: React.FC = () => {
|
||||
</TabsList>
|
||||
{useCases.tabs.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value} className="mt-6">
|
||||
<Card className="border-gray-100 shadow-md dark:border-gray-800">
|
||||
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-2xl">
|
||||
{tab.label}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base text-gray-600 dark:text-gray-300">
|
||||
{tab.goal}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<Card className="border-gray-100 shadow-md dark:border-gray-800">
|
||||
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-2xl">
|
||||
{tab.label}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base text-gray-600 dark:text-gray-300">
|
||||
{tab.goal}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
@@ -362,29 +402,31 @@ const HowItWorks: React.FC = () => {
|
||||
|
||||
<section className="container mx-auto px-4 py-16">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Card className="border-gray-100 shadow-sm dark:border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle>{checklist.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('how_it_works_page.labels.prep_hint', 'Alles, was du vor dem Event abhaken solltest.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3 text-gray-600 dark:text-gray-300">
|
||||
{checklist.items.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 text-pink-500" aria-hidden />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button asChild className="mt-6 bg-pink-500 hover:bg-pink-600">
|
||||
<Link href={localizedPath('/packages')}>
|
||||
{checklist.cta}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<Card className="border-gray-100 shadow-sm dark:border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle>{checklist.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('how_it_works_page.labels.prep_hint', 'Alles, was du vor dem Event abhaken solltest.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3 text-gray-600 dark:text-gray-300">
|
||||
{checklist.items.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 text-pink-500" aria-hidden />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button asChild className="mt-6 bg-pink-500 hover:bg-pink-600">
|
||||
<Link href={localizedPath('/packages')}>
|
||||
{checklist.cta}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
const Kontakt: React.FC = () => {
|
||||
const { data, setData, post, processing, errors, reset } = useForm({
|
||||
@@ -16,6 +17,7 @@ const Kontakt: React.FC = () => {
|
||||
const { flash } = usePage<{ flash?: { success?: string } }>().props;
|
||||
const { t } = useTranslation('marketing');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -34,7 +36,12 @@ const Kontakt: React.FC = () => {
|
||||
<MarketingLayout 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="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>
|
||||
<p className="text-center text-gray-600 dark:text-gray-300 mb-8 font-sans-marketing">{t('kontakt.description')}</p>
|
||||
{flash?.success && (
|
||||
@@ -120,7 +127,7 @@ const Kontakt: React.FC = () => {
|
||||
<div className="mt-8 text-center">
|
||||
<Link href={localizedPath('/')} className="text-[#FFB6C1] hover:underline font-sans-marketing">{t('kontakt.back_home')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</MarketingLayout>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
interface OccasionsProps {
|
||||
type: string;
|
||||
@@ -17,6 +18,20 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const locale = i18n.language || 'de';
|
||||
const isEn = locale.toLowerCase().startsWith('en');
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const viewportOnce = { once: true, amount: 0.25 };
|
||||
const revealUp = {
|
||||
hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 18 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
|
||||
},
|
||||
};
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.12 } },
|
||||
};
|
||||
|
||||
const occasionsContent = {
|
||||
hochzeit: {
|
||||
@@ -123,9 +138,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
<MarketingLayout 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">
|
||||
<div className="container mx-auto max-w-5xl space-y-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
|
||||
<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">
|
||||
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
|
||||
{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">
|
||||
{t('nav.discover_packages')}
|
||||
</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">
|
||||
<CardContent className="space-y-8 p-6 md:p-10">
|
||||
<motion.div variants={revealUp}>
|
||||
<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="space-y-5">
|
||||
<Badge
|
||||
@@ -199,15 +215,16 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white px-4 py-14 dark:bg-gray-950">
|
||||
<div className="container mx-auto max-w-6xl space-y-10">
|
||||
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-8">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-6xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<motion.div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]" variants={stagger}>
|
||||
<motion.div className="space-y-8" variants={revealUp}>
|
||||
<div>
|
||||
<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">
|
||||
@@ -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>
|
||||
</ol>
|
||||
</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">
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</motion.aside>
|
||||
</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">
|
||||
<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">
|
||||
What couples say
|
||||
</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-200">
|
||||
“We ended up with more than 800 guest photos – from little mishaps to the most emotional moments. Our
|
||||
photographer loved how well the Fotospiel App complemented her work.”
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<motion.div variants={revealUp}>
|
||||
<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">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
What couples say
|
||||
</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-200">
|
||||
“We ended up with more than 800 guest photos – from little mishaps to the most emotional moments. Our
|
||||
photographer loved how well the Fotospiel App complemented her work.”
|
||||
</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">
|
||||
Next step
|
||||
</p>
|
||||
<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
|
||||
upgrade later at any time.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button asChild size="sm" className="bg-[#FFB6C1] text-white hover:bg-[#FF69B4]">
|
||||
<Link href={localizedPath('/demo')}>{t('home.cta_demo')}</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
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"
|
||||
>
|
||||
<Link href={localizedPath('/packages')}>{t('occasions.cta')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<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">
|
||||
Next step
|
||||
</p>
|
||||
<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
|
||||
upgrade later at any time.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button asChild size="sm" className="bg-[#FFB6C1] text-white hover:bg-[#FF69B4]">
|
||||
<Link href={localizedPath('/demo')}>{t('home.cta_demo')}</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
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"
|
||||
>
|
||||
<Link href={localizedPath('/packages')}>{t('occasions.cta')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</MarketingLayout>
|
||||
);
|
||||
}
|
||||
@@ -363,9 +384,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
<MarketingLayout 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">
|
||||
<div className="container mx-auto max-w-5xl space-y-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
|
||||
<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">
|
||||
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
|
||||
{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">
|
||||
{t('nav.discover_packages')}
|
||||
</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">
|
||||
<CardContent className="space-y-8 p-6 md:p-10">
|
||||
<motion.div variants={revealUp}>
|
||||
<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="space-y-5">
|
||||
<Badge
|
||||
@@ -438,14 +460,15 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white px-4 py-14 dark:bg-gray-950">
|
||||
<div className="container mx-auto max-w-6xl space-y-10">
|
||||
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-6xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<motion.div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]" variants={revealUp}>
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<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>
|
||||
</Card>
|
||||
</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">
|
||||
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<motion.div variants={revealUp}>
|
||||
<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">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Was Paare berichten
|
||||
@@ -566,7 +592,7 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</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">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Nächster Schritt
|
||||
@@ -590,8 +616,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</MarketingLayout>
|
||||
);
|
||||
@@ -604,9 +631,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
<MarketingLayout 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">
|
||||
<div className="container mx-auto max-w-5xl space-y-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
|
||||
<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">
|
||||
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
|
||||
{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">
|
||||
{t('nav.discover_packages')}
|
||||
</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">
|
||||
<CardContent className="space-y-8 p-6 md:p-10">
|
||||
<motion.div variants={revealUp}>
|
||||
<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="space-y-5">
|
||||
<Badge
|
||||
@@ -679,16 +707,17 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white px-4 py-14 dark:bg-gray-950">
|
||||
<div className="container mx-auto max-w-6xl space-y-10">
|
||||
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-6xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<motion.div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]" variants={revealUp}>
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<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>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</Card>
|
||||
</aside>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={revealUp}>
|
||||
<Separator className="my-8" />
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={revealUp}>
|
||||
<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">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
What hosts say
|
||||
</p>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
What hosts say
|
||||
</p>
|
||||
<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
|
||||
hundreds of chat messages.”
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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">
|
||||
Next step
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next step
|
||||
</p>
|
||||
<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
|
||||
@@ -833,8 +865,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</MarketingLayout>
|
||||
);
|
||||
}
|
||||
@@ -844,9 +877,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
<MarketingLayout 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">
|
||||
<div className="container mx-auto max-w-5xl space-y-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
|
||||
<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">
|
||||
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
|
||||
{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">
|
||||
{t('nav.discover_packages')}
|
||||
</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">
|
||||
<CardContent className="space-y-8 p-6 md:p-10">
|
||||
<motion.div variants={revealUp}>
|
||||
<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="space-y-5">
|
||||
<Badge
|
||||
@@ -920,14 +954,15 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white px-4 py-14 dark:bg-gray-950">
|
||||
<div className="container mx-auto max-w-6xl space-y-10">
|
||||
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-6xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<motion.div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]" variants={revealUp}>
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<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>
|
||||
</Card>
|
||||
</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">
|
||||
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<motion.div variants={revealUp}>
|
||||
<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">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Was Gastgeber:innen berichten
|
||||
@@ -1047,7 +1085,7 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</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">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Nächster Schritt
|
||||
@@ -1071,8 +1109,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</MarketingLayout>
|
||||
);
|
||||
@@ -1085,9 +1124,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
<MarketingLayout 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">
|
||||
<div className="container mx-auto max-w-5xl space-y-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
|
||||
<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">
|
||||
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
|
||||
{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">
|
||||
{t('nav.discover_packages')}
|
||||
</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">
|
||||
<CardContent className="space-y-8 p-6 md:p-10">
|
||||
<motion.div variants={revealUp}>
|
||||
<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="space-y-5">
|
||||
<Badge
|
||||
@@ -1159,16 +1199,17 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white px-4 py-14 dark:bg-gray-950">
|
||||
<div className="container mx-auto max-w-6xl space-y-10">
|
||||
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-6xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<motion.div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]" variants={revealUp}>
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<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>
|
||||
</Card>
|
||||
</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">
|
||||
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<motion.div variants={revealUp}>
|
||||
<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">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
What companies say
|
||||
@@ -1288,7 +1332,7 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</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">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next step
|
||||
@@ -1312,8 +1356,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</MarketingLayout>
|
||||
);
|
||||
@@ -1324,9 +1369,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
<MarketingLayout 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">
|
||||
<div className="container mx-auto max-w-5xl space-y-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
|
||||
<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">
|
||||
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
|
||||
{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">
|
||||
{t('nav.discover_packages')}
|
||||
</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">
|
||||
<CardContent className="space-y-8 p-6 md:p-10">
|
||||
<motion.div variants={revealUp}>
|
||||
<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="space-y-5">
|
||||
<Badge
|
||||
@@ -1400,14 +1446,15 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white px-4 py-14 dark:bg-gray-950">
|
||||
<div className="container mx-auto max-w-6xl space-y-10">
|
||||
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-6xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<motion.div className="grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]" variants={revealUp}>
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<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>
|
||||
</Card>
|
||||
</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">
|
||||
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<motion.div variants={revealUp}>
|
||||
<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">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Was Unternehmen berichten
|
||||
@@ -1528,7 +1578,7 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</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="spacey-3 space-y-3 p-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Nächster Schritt
|
||||
@@ -1552,8 +1602,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</MarketingLayout>
|
||||
);
|
||||
@@ -1567,9 +1618,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
<MarketingLayout 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">
|
||||
<div className="container mx-auto max-w-5xl space-y-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
|
||||
<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">
|
||||
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
|
||||
{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">
|
||||
{t('nav.discover_packages')}
|
||||
</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">
|
||||
<CardContent className="space-y-6 p-6 md:p-10">
|
||||
<motion.div variants={revealUp}>
|
||||
<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">
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -1613,14 +1665,15 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white px-4 py-14 dark:bg-gray-950">
|
||||
<div className="container mx-auto max-w-5xl space-y-10">
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-5xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<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">
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</MarketingLayout>
|
||||
);
|
||||
@@ -1658,9 +1711,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
<MarketingLayout 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">
|
||||
<div className="container mx-auto max-w-5xl space-y-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-5xl space-y-8" variants={stagger} initial="hidden" animate="visible">
|
||||
<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">
|
||||
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
|
||||
{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">
|
||||
{t('nav.discover_packages')}
|
||||
</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">
|
||||
<CardContent className="space-y-6 p-6 md:p-10">
|
||||
<motion.div variants={revealUp}>
|
||||
<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">
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -1704,14 +1758,15 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white px-4 py-14 dark:bg-gray-950">
|
||||
<div className="container mx-auto max-w-5xl space-y-10">
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<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">
|
||||
<motion.div className="container mx-auto max-w-5xl space-y-10" variants={stagger} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<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">
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</MarketingLayout>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import { ArrowRight, Check, Star } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
interface Package {
|
||||
id: number;
|
||||
@@ -325,11 +326,26 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
||||
const { t } = useTranslation('marketing');
|
||||
const { t: tCommon } = useTranslation('common');
|
||||
const { flash } = usePage<{ flash?: { error?: string } }>().props;
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const {
|
||||
variant: packagesHeroVariant,
|
||||
trackClick: trackPackagesHeroClick,
|
||||
} = useCtaExperiment('packages_hero_cta');
|
||||
|
||||
const viewportOnce = { once: true, amount: 0.25 };
|
||||
const revealUp = {
|
||||
hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 18 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
|
||||
},
|
||||
};
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.12 } },
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (flash?.error) {
|
||||
toast.error(flash.error);
|
||||
@@ -960,8 +976,13 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
||||
return (
|
||||
<MarketingLayout title={t('packages.title')}>
|
||||
<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">
|
||||
<div className="space-y-3 md:space-y-4">
|
||||
<motion.div
|
||||
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">
|
||||
{t('packages.for_endcustomers')} · {t('packages.for_resellers')}
|
||||
</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">
|
||||
{t('packages.hero_description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
</motion.div>
|
||||
<motion.div className="flex flex-wrap items-center justify-center gap-3" variants={revealUp}>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
@@ -1011,11 +1032,11 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
||||
{t('packages.gift_cta', 'Paket verschenken')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
</motion.div>
|
||||
<motion.p className="text-sm text-gray-600 dark:text-gray-300" variants={revealUp}>
|
||||
{t('packages.hero_secondary')}
|
||||
</p>
|
||||
</div>
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section id="packages-showcase" className="px-4 py-16 md:py-20">
|
||||
@@ -1045,14 +1066,16 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
||||
>
|
||||
{orderedEndcustomerPackages.map((pkg) => (
|
||||
<div key={pkg.id} className="snap-start basis-[72vw] shrink-0 sm:basis-[60vw]">
|
||||
<PackageCard
|
||||
pkg={pkg}
|
||||
variant="endcustomer"
|
||||
highlight={pkg.id === highlightEndcustomerId}
|
||||
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
|
||||
className="h-full"
|
||||
compact
|
||||
/>
|
||||
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<PackageCard
|
||||
pkg={pkg}
|
||||
variant="endcustomer"
|
||||
highlight={pkg.id === highlightEndcustomerId}
|
||||
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
|
||||
className="h-full"
|
||||
compact
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1060,18 +1083,27 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
||||
</div>
|
||||
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
|
||||
{orderedEndcustomerPackages.map((pkg) => (
|
||||
<PackageCard
|
||||
<motion.div
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
variant="endcustomer"
|
||||
highlight={pkg.id === highlightEndcustomerId}
|
||||
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
|
||||
className="h-full"
|
||||
compact
|
||||
/>
|
||||
variants={revealUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
>
|
||||
<PackageCard
|
||||
pkg={pkg}
|
||||
variant="endcustomer"
|
||||
highlight={pkg.id === highlightEndcustomerId}
|
||||
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
|
||||
className="h-full"
|
||||
compact
|
||||
/>
|
||||
</motion.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 value="reseller" className="space-y-8">
|
||||
@@ -1086,14 +1118,16 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
||||
>
|
||||
{orderedResellerPackages.map((pkg) => (
|
||||
<div key={pkg.id} className="snap-start basis-[72vw] shrink-0 sm:basis-[60vw]">
|
||||
<PackageCard
|
||||
pkg={pkg}
|
||||
variant="reseller"
|
||||
highlight={pkg.id === highlightResellerId}
|
||||
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
||||
className="h-full"
|
||||
compact
|
||||
/>
|
||||
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<PackageCard
|
||||
pkg={pkg}
|
||||
variant="reseller"
|
||||
highlight={pkg.id === highlightResellerId}
|
||||
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
||||
className="h-full"
|
||||
compact
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1101,18 +1135,27 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
||||
</div>
|
||||
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
|
||||
{orderedResellerPackages.map((pkg) => (
|
||||
<PackageCard
|
||||
<motion.div
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
variant="reseller"
|
||||
highlight={pkg.id === highlightResellerId}
|
||||
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
||||
className="h-full"
|
||||
compact
|
||||
/>
|
||||
variants={revealUp}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportOnce}
|
||||
>
|
||||
<PackageCard
|
||||
pkg={pkg}
|
||||
variant="reseller"
|
||||
highlight={pkg.id === highlightResellerId}
|
||||
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
||||
className="h-full"
|
||||
compact
|
||||
/>
|
||||
</motion.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>
|
||||
</Tabs>
|
||||
</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_payment'), body: t('packages.faq_payment_desc') },
|
||||
].map((item) => (
|
||||
<Card key={item.title} className="h-full border border-gray-200/70 dark:border-gray-800/70">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-display">{item.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{item.body}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<motion.div key={item.title} variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||
<Card className="h-full border border-gray-200/70 dark:border-gray-800/70">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-display">{item.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{item.body}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
91
resources/js/pages/marketing/__tests__/Blog.render.test.tsx
Normal file
91
resources/js/pages/marketing/__tests__/Blog.render.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
67
resources/js/pages/marketing/__tests__/Demo.render.test.tsx
Normal file
67
resources/js/pages/marketing/__tests__/Demo.render.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
82
resources/js/pages/marketing/__tests__/Home.render.test.tsx
Normal file
82
resources/js/pages/marketing/__tests__/Home.render.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
140
resources/js/pages/marketing/__tests__/Packages.render.test.tsx
Normal file
140
resources/js/pages/marketing/__tests__/Packages.render.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user