Files
fotospiel-app/resources/js/pages/marketing/Packages.tsx
Codex Agent 2e78f3ab8d
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
tests / ui (push) Waiting to run
Update marketing packages and checkout copy
2026-02-01 13:04:11 +01:00

1540 lines
60 KiB
TypeScript

import React, { useState, useEffect, useMemo, useRef, useLayoutEffect } from 'react';
import { Link, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import MarketingLayout from '@/layouts/mainWebsite';
import { useAnalytics } from '@/hooks/useAnalytics';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useLocale } from '@/hooks/useLocale';
import { ArrowRight, Check, Gift, Headphones, LayoutGrid, Star } from 'lucide-react';
import toast from 'react-hot-toast';
import { motion, useReducedMotion } from 'framer-motion';
interface Package {
id: number;
name: string;
slug: string;
description: string;
description_breakdown: DescriptionEntry[];
gallery_duration_label?: string;
price: number;
events: number | null;
features: string[];
included_package_slug?: string | null;
max_events_per_year?: number | null;
limits?: {
max_photos?: number;
max_guests?: number;
max_tenants?: number;
max_events_per_year?: number;
gallery_days?: number;
};
watermark_allowed?: boolean;
branding_allowed?: boolean;
}
export const resolveWatermarkFeatureKey = (pkg: Package): string => {
if (pkg.slug === 'starter') {
return 'watermark_base';
}
if (pkg.slug === 'standard') {
return 'no_watermark';
}
if (pkg.slug === 'pro') {
return 'watermark_custom';
}
const features = Array.isArray(pkg.features) ? pkg.features : [];
if (pkg.watermark_allowed === false) {
return 'watermark_base';
}
if (features.includes('no_watermark')) {
return 'no_watermark';
}
return pkg.watermark_allowed === true ? 'watermark_custom' : 'watermark';
};
const sortPackagesByPrice = (packages: Package[]): Package[] =>
[...packages].sort((a, b) => Number(a.price ?? 0) - Number(b.price ?? 0));
interface PackageComparisonProps {
packages: Package[];
variant: 'endcustomer' | 'reseller';
serviceTierNames?: Record<string, string>;
}
const buildDisplayFeatures = (pkg: Package, variant: 'endcustomer' | 'reseller'): string[] => {
const features = [...pkg.features];
const removeFeature = (key: string) => {
const index = features.indexOf(key);
if (index !== -1) {
features.splice(index, 1);
}
};
const addFeature = (key: string) => {
if (!features.includes(key)) {
features.push(key);
}
};
if (variant === 'endcustomer') {
const watermarkFeature = resolveWatermarkFeatureKey(pkg);
['watermark', 'no_watermark', 'watermark_base', 'watermark_custom'].forEach(removeFeature);
addFeature(watermarkFeature);
if (pkg.branding_allowed) {
addFeature('custom_branding');
} else {
removeFeature('custom_branding');
}
}
addFeature('photobooth_connect');
return Array.from(new Set(features));
};
function PackageComparison({ packages, variant, serviceTierNames = {} }: PackageComparisonProps) {
const { t } = useTranslation('marketing');
const { t: tCommon } = useTranslation('common');
if (packages.length === 0) {
return null;
}
const formatPrice = (pkg: Package) =>
pkg.price === 0 ? t('packages.free') : `${pkg.price.toLocaleString()} ${t('packages.currency.euro')}`;
const limits =
variant === 'endcustomer'
? [
{
key: 'price',
label: t('packages.price'),
value: (pkg: Package) => `${formatPrice(pkg)} / ${t('packages.billing_per_event')}`,
},
{
key: 'max_photos',
label: t('packages.max_photos_label'),
value: (pkg: Package) => pkg.limits?.max_photos?.toLocaleString() ?? tCommon('unlimited'),
},
{
key: 'max_guests',
label: t('packages.max_guests_label'),
value: (pkg: Package) => pkg.limits?.max_guests?.toLocaleString() ?? tCommon('unlimited'),
},
{
key: 'gallery_days',
label: t('packages.gallery_days_label'),
value: (pkg: Package) =>
pkg.gallery_duration_label ??
pkg.limits?.gallery_days?.toLocaleString() ??
tCommon('unlimited'),
},
]
: [
{
key: 'price',
label: t('packages.price'),
value: (pkg: Package) => `${formatPrice(pkg)} / ${t('packages.billing_per_kontingent')}`,
},
{
key: 'included_package_slug',
label: t('packages.included_package_label', 'Inklusive Event-Level'),
value: (pkg: Package) => {
const slug = pkg.included_package_slug ?? null;
if (!slug) {
return tCommon('unlimited');
}
return serviceTierNames[slug] ?? slug;
},
},
{
key: 'max_events_per_year',
label: t('packages.max_events_year'),
value: (pkg: Package) =>
pkg.limits?.max_events_per_year?.toLocaleString() ?? tCommon('unlimited'),
},
];
const features =
variant === 'endcustomer'
? [
{
key: 'watermark',
label: t('packages.watermark_label'),
value: (pkg: Package) => t(`packages.feature_${resolveWatermarkFeatureKey(pkg)}`),
},
{
key: 'branding',
label: t('packages.feature_custom_branding'),
value: (pkg: Package) => (pkg.branding_allowed ? t('packages.available') : t('packages.not_available')),
},
{
key: 'support',
label: t('packages.feature_support'),
value: (pkg: Package) =>
pkg.features.includes('priority_support') ? t('packages.priority_support') : t('packages.standard_support'),
},
{
key: 'photobooth_connect',
label: t('packages.feature_photobooth_connect'),
value: () => t('packages.available'),
},
]
: [
{
key: 'recommended_usage_window',
label: t('packages.recommended_usage_label', 'Empfehlung'),
value: () => t('packages.recommended_usage_window'),
},
{
key: 'support',
label: t('packages.feature_support'),
value: (pkg: Package) =>
pkg.features.includes('priority_support') ? t('packages.priority_support') : t('packages.standard_support'),
},
{
key: 'dashboard',
label: t('packages.feature_reseller_dashboard'),
value: (pkg: Package) =>
pkg.features.includes('reseller_dashboard') ? t('packages.available') : t('packages.not_available'),
},
{
key: 'reporting',
label: t('packages.feature_advanced_reporting'),
value: (pkg: Package) =>
pkg.features.includes('advanced_reporting') ? t('packages.available') : t('packages.not_available'),
},
{
key: 'photobooth_connect',
label: t('packages.feature_photobooth_connect'),
value: () => t('packages.available'),
},
];
return (
<div className="space-y-4">
<div>
<h3 className="text-2xl font-semibold font-display">{t('packages.comparison_title')}</h3>
<p className="text-sm text-muted-foreground">{t('packages.comparison_subtitle')}</p>
</div>
<Tabs defaultValue="limits">
<TabsList className="grid w-full max-w-sm grid-cols-2 rounded-full">
<TabsTrigger value="limits">{t('packages.comparison_limits')}</TabsTrigger>
<TabsTrigger value="features">{t('packages.comparison_features')}</TabsTrigger>
</TabsList>
<TabsContent value="limits">
<Card className="overflow-hidden">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('packages.feature')}</TableHead>
{packages.map((pkg) => (
<TableHead key={pkg.id} className="text-center">
{pkg.name}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{limits.map((row) => (
<TableRow key={row.key}>
<TableCell className="font-medium">{row.label}</TableCell>
{packages.map((pkg) => (
<TableCell key={`${row.key}-${pkg.id}`} className="text-center">
{row.value(pkg)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="features">
<Card className="overflow-hidden">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('packages.feature')}</TableHead>
{packages.map((pkg) => (
<TableHead key={pkg.id} className="text-center">
{pkg.name}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{features.map((row) => (
<TableRow key={row.key}>
<TableCell className="font-medium">{row.label}</TableCell>
{packages.map((pkg) => (
<TableCell key={`${row.key}-${pkg.id}`} className="text-center">
{row.value(pkg)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
type DescriptionEntry = {
title?: string | null;
value: string;
};
interface PackagesProps {
endcustomerPackages: Package[];
resellerPackages: Package[];
}
const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackages }) => {
const [open, setOpen] = useState(false);
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
const serviceTierNames = useMemo(() => {
const map: Record<string, string> = {};
endcustomerPackages.forEach((pkg) => {
if (pkg?.slug) {
map[pkg.slug] = pkg.name;
}
});
return map;
}, [endcustomerPackages]);
const [isMobile, setIsMobile] = useState(false);
const dialogScrollRef = useRef<HTMLDivElement | null>(null);
const dialogHeadingRef = useRef<HTMLDivElement | null>(null);
const mobileEndcustomerRef = useRef<HTMLDivElement | null>(null);
const mobileResellerRef = useRef<HTMLDivElement | null>(null);
const { localizedPath } = useLocalizedRoutes();
const locale = useLocale();
const { t } = useTranslation('marketing');
const { t: tCommon } = useTranslation('common');
const { flash } = usePage<{ flash?: { error?: string } }>().props;
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 } },
};
useEffect(() => {
if (flash?.error) {
toast.error(flash.error);
}
}, [flash?.error]);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const packageId = urlParams.get('package_id');
if (packageId) {
const id = parseInt(packageId);
const pkg = [...endcustomerPackages, ...resellerPackages].find(p => p.id === id);
if (pkg) {
setSelectedPackage(pkg);
setOpen(true);
setCurrentStep('overview');
}
}
}, [endcustomerPackages, resellerPackages]);
useLayoutEffect(() => {
if (open && dialogScrollRef.current) {
dialogScrollRef.current.scrollTo({ top: 0 });
}
}, [open, selectedPackage]);
const highlightEndcustomerId = useMemo(
() => selectHighlightPackageId(endcustomerPackages),
[endcustomerPackages],
);
const highlightResellerId = useMemo(
() => selectHighlightPackageId(resellerPackages),
[resellerPackages],
);
const orderedEndcustomerPackages = useMemo(
() => sortPackagesByPrice(endcustomerPackages),
[endcustomerPackages],
);
const orderedResellerPackages = useMemo(
() => sortPackagesByPrice(resellerPackages),
[resellerPackages],
);
const highlightResellerPackage = useMemo(
() => orderedResellerPackages.find((pkg) => pkg.id === highlightResellerId) ?? null,
[orderedResellerPackages, highlightResellerId],
);
const resellerBundles = useMemo(
() => orderedResellerPackages.filter((pkg) => pkg.id !== highlightResellerId),
[orderedResellerPackages, highlightResellerId],
);
const guestSliderMin = 1;
const guestSliderMax = useMemo(() => {
const limits = orderedEndcustomerPackages
.map((pkg) => pkg.limits?.max_guests ?? pkg.max_guests)
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value));
if (limits.length === 0) {
return 500;
}
return Math.max(500, ...limits);
}, [orderedEndcustomerPackages]);
const [guestCount, setGuestCount] = useState(() => Math.min(100, guestSliderMax));
useEffect(() => {
setGuestCount((current) => {
if (current < guestSliderMin) {
return guestSliderMin;
}
if (current > guestSliderMax) {
return guestSliderMax;
}
return current;
});
}, [guestSliderMax, guestSliderMin]);
const recommendedPackage = useMemo(() => {
if (orderedEndcustomerPackages.length === 0) {
return null;
}
const match = orderedEndcustomerPackages.find((pkg) => {
const limit = pkg.limits?.max_guests ?? pkg.max_guests;
const limitValue = typeof limit === 'number' ? limit : Number.POSITIVE_INFINITY;
return limitValue >= guestCount;
});
return match ?? orderedEndcustomerPackages[orderedEndcustomerPackages.length - 1];
}, [orderedEndcustomerPackages, guestCount]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const media = window.matchMedia('(max-width: 768px)');
const update = () => setIsMobile(media.matches);
update();
if (media.addEventListener) {
media.addEventListener('change', update);
} else {
media.addListener(update);
}
return () => {
if (media.removeEventListener) {
media.removeEventListener('change', update);
} else {
media.removeListener(update);
}
};
}, []);
const scrollMobileListToHighlight = (
container: HTMLDivElement | null,
packages: Package[],
highlightId: number | null,
) => {
if (!container || !highlightId) {
return;
}
const index = packages.findIndex((pkg) => pkg.id === highlightId);
if (index < 0) {
return;
}
const child = container.children[index] as HTMLElement | undefined;
if (!child) {
return;
}
const targetLeft = child.offsetLeft - container.clientWidth / 2 + child.clientWidth / 2;
container.scrollTo({ left: Math.max(targetLeft, 0), behavior: 'smooth' });
};
useLayoutEffect(() => {
scrollMobileListToHighlight(mobileEndcustomerRef.current, orderedEndcustomerPackages, highlightEndcustomerId);
}, [orderedEndcustomerPackages, highlightEndcustomerId]);
useLayoutEffect(() => {
scrollMobileListToHighlight(mobileResellerRef.current, orderedResellerPackages, highlightResellerId);
}, [orderedResellerPackages, highlightResellerId]);
const testimonialsByPackage = useMemo(() => {
const raw = t('packages.testimonials', { returnObjects: true });
if (!raw || typeof raw !== 'object') {
return {};
}
return raw as Record<string, { name?: string; text?: string; rating?: number }[]>;
}, [locale, t]);
const testimonials = useMemo(() => {
if (!selectedPackage) {
return [];
}
const entries = testimonialsByPackage[selectedPackage.slug] ?? testimonialsByPackage.default ?? [];
if (!Array.isArray(entries)) {
return [];
}
return entries.map((entry) => ({
name: entry.name ?? '',
text: entry.text ?? '',
rating: entry.rating ?? 4,
}));
}, [selectedPackage, testimonialsByPackage]);
const renderDetailBody = (wrapperClass: string) => {
if (!selectedPackage) {
return null;
}
return (
<div ref={dialogScrollRef} className={wrapperClass}>
<div ref={dialogHeadingRef} tabIndex={-1} className="outline-none">
<DialogHeader className="space-y-3 text-left">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-400">
{selectedVariant === 'reseller' ? t('packages.subscription') : t('packages.one_time')}
</p>
<DialogTitle className="text-3xl font-display text-gray-900">
{selectedPackage.name}
</DialogTitle>
<p className="text-base text-gray-600">{selectedPackage.description}</p>
</DialogHeader>
</div>
<PackageDetailGrid
packageData={selectedPackage}
variant={selectedVariant}
isHighlight={selectedHighlight}
purchaseUrl={purchaseUrl}
onCtaClick={() => {
handleCtaClick(selectedPackage, selectedVariant);
localStorage.setItem('preferred_package', JSON.stringify(selectedPackage));
}}
t={t}
tCommon={tCommon}
testimonials={testimonials}
close={() => setOpen(false)}
/>
</div>
);
};
function selectHighlightPackageId(packages: Package[]): number | null {
const count = packages.length;
if (count <= 1) {
return null;
}
const sortedByPrice = [...packages].sort((a, b) => a.price - b.price);
if (count === 2) {
return sortedByPrice[1]?.id ?? null;
}
if (count === 3) {
return sortedByPrice[1]?.id ?? null;
}
return sortedByPrice[count - 2]?.id ?? null;
}
function isHighlightedPackage(pkg: Package, variant: 'endcustomer' | 'reseller') {
return variant === 'reseller' ? pkg.id === highlightResellerId : pkg.id === highlightEndcustomerId;
}
const selectedVariant = useMemo<'endcustomer' | 'reseller'>(() => {
if (!selectedPackage) return 'endcustomer';
return resellerPackages.some((pkg) => pkg.id === selectedPackage.id) ? 'reseller' : 'endcustomer';
}, [selectedPackage, resellerPackages]);
const selectedHighlight = selectedPackage
? isHighlightedPackage(selectedPackage, selectedVariant)
: false;
const purchaseUrl = selectedPackage ? localizedPath(`/bestellen/${selectedPackage.id}`) : '#';
const { trackEvent } = useAnalytics();
const handleCardClick = (pkg: Package, variant: 'endcustomer' | 'reseller') => {
trackEvent({
category: 'marketing_packages',
action: 'open_dialog',
name: `${variant}:${pkg.name}`,
value: pkg.price,
});
setSelectedPackage(pkg);
setOpen(true);
};
const handleCtaClick = (pkg: Package, variant: 'endcustomer' | 'reseller') => {
trackEvent({
category: 'marketing_packages',
action: 'cta_dialog',
name: `${variant}:${pkg.name}`,
value: pkg.price,
});
};
// nextStep entfernt, da Tabs nun parallel sind
const getAccentTheme = (variant: 'endcustomer' | 'reseller') =>
variant === 'reseller'
? {
badge: 'border border-amber-200/70 bg-amber-100 text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/20 dark:text-amber-100',
price: 'text-amber-600 dark:text-amber-200',
buttonHighlight: 'bg-gray-900 text-white hover:bg-gray-800 dark:bg-amber-600 dark:hover:bg-amber-500',
buttonDefault: 'border border-amber-200 text-amber-700 hover:bg-amber-50 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10',
cardBorder: 'border-amber-200/70 dark:border-amber-500/40',
highlightShadow: 'shadow-xl shadow-amber-200/40 ring-1 ring-amber-200/70 dark:shadow-amber-900/40 dark:ring-amber-500/30',
}
: {
badge: 'border border-rose-200/70 bg-rose-100 text-rose-700 dark:border-pink-500/40 dark:bg-pink-500/20 dark:text-pink-100',
price: 'text-rose-600 dark:text-pink-200',
buttonHighlight: 'bg-gray-900 text-white hover:bg-gray-800 dark:bg-pink-600 dark:hover:bg-pink-500',
buttonDefault: 'border border-rose-200 text-rose-700 hover:bg-rose-50 dark:border-pink-500/40 dark:text-pink-100 dark:hover:bg-pink-500/10',
cardBorder: 'border-rose-200/70 dark:border-pink-500/40',
highlightShadow: 'shadow-xl shadow-rose-200/40 ring-1 ring-rose-200/70 dark:shadow-pink-900/40 dark:ring-pink-500/30',
};
type PackageMetric = {
key: string;
label: string;
value: string;
};
const resolveServiceTierLabel = (slug: string | null | undefined): string => {
if (!slug) {
return '';
}
if (slug === 'starter') {
return 'Starter';
}
if (slug === 'standard') {
return 'Classic';
}
if (slug === 'pro') {
return 'Premium';
}
return slug;
};
const resolvePackageMetrics = (
pkg: Package,
variant: 'endcustomer' | 'reseller',
t: TFunction,
tCommon: TFunction,
): PackageMetric[] => {
if (variant === 'reseller') {
return [
{
key: 'included_package_slug',
label: t('packages.included_package_label', 'Inklusive Event-Level'),
value: resolveServiceTierLabel(pkg.included_package_slug) || tCommon('unlimited'),
},
{
key: 'max_events_per_year',
label: t('packages.max_events_year'),
value: pkg.limits?.max_events_per_year
? pkg.limits.max_events_per_year.toLocaleString()
: tCommon('unlimited'),
},
{
key: 'recommended_usage_window',
label: t('packages.recommended_usage_label', 'Empfehlung'),
value: t('packages.recommended_usage_window'),
},
];
}
return [
{
key: 'max_photos',
label: t('packages.max_photos_label'),
value: pkg.limits?.max_photos
? pkg.limits.max_photos.toLocaleString()
: tCommon('unlimited'),
},
{
key: 'max_guests',
label: t('packages.max_guests_label'),
value: pkg.limits?.max_guests
? pkg.limits.max_guests.toLocaleString()
: tCommon('unlimited'),
},
{
key: 'gallery_days',
label: t('packages.gallery_days_label'),
value: pkg.gallery_duration_label
?? (pkg.limits?.gallery_days
? pkg.limits.gallery_days.toLocaleString()
: tCommon('unlimited')),
},
];
};
interface PackageCardProps {
pkg: Package;
variant: 'endcustomer' | 'reseller';
highlight?: boolean;
onSelect?: (pkg: Package) => void;
onCtaClick?: (pkg: Package, variant: 'endcustomer' | 'reseller') => void;
className?: string;
showCTA?: boolean;
ctaLabel?: string;
compact?: boolean;
}
function PackageCard({
pkg,
variant,
highlight = false,
onSelect,
onCtaClick,
className,
showCTA = true,
ctaLabel,
compact = false,
}: PackageCardProps) {
const { t } = useTranslation('marketing');
const { t: tCommon } = useTranslation('common');
const { localizedPath } = useLocalizedRoutes();
const accent = getAccentTheme(variant);
const purchaseUrl = localizedPath(`/bestellen/${pkg.id}`);
const numericPrice = Number(pkg.price);
const priceLabel =
numericPrice === 0
? t('packages.free')
: `${numericPrice.toLocaleString()} ${t('packages.currency.euro')}`;
const cadenceLabel =
variant === 'reseller'
? t('packages.billing_per_kontingent')
: t('packages.billing_per_event');
const typeLabel =
variant === 'reseller' ? t('packages.subscription') : t('packages.one_time');
const badgeLabel = highlight
? (variant === 'reseller'
? t('packages.badge_best_value')
: t('packages.badge_recommended'))
: pkg.price === 0
? t('packages.badge_starter')
: null;
const eventBadge = variant === 'reseller' && pkg.events
? t('packages.events_badge', { count: pkg.events, defaultValue: `${pkg.events} Events` })
: null;
const displayFeatures = buildDisplayFeatures(pkg, variant);
const visibleFeatures = displayFeatures.slice(0, 5);
const metrics = resolvePackageMetrics(pkg, variant, t, tCommon);
const metricList = (
<ul className="space-y-2 text-xs text-gray-700 dark:text-gray-200">
{metrics.map((metric) => (
<li key={metric.key} className="flex items-start gap-2">
<Check className="mt-0.5 h-3.5 w-3.5 text-gray-900 dark:text-gray-100" />
<div className="flex flex-wrap items-baseline gap-1">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{metric.value}</span>
<span className="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400">{metric.label}</span>
</div>
</li>
))}
</ul>
);
const featureList = (
<ul className="space-y-2 text-xs text-gray-700 dark:text-gray-200">
{visibleFeatures.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="mt-0.5 h-3.5 w-3.5 text-gray-900 dark:text-gray-100" />
<span>{t(`packages.feature_${feature}`)}</span>
</li>
))}
</ul>
);
return (
<Card
className={cn(
'flex h-full flex-col rounded-3xl border border-gray-100 bg-white text-gray-900 shadow-sm transition hover:shadow-lg dark:border-gray-800 dark:bg-gray-900/90 dark:text-gray-50',
compact && 'gap-4 py-5',
highlight && `${accent.cardBorder} ${accent.highlightShadow} md:-translate-y-2 md:scale-[1.02]`,
className,
)}
>
<CardHeader className={cn('gap-3', compact && 'px-5')}>
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] font-semibold uppercase tracking-[0.25em] text-gray-500 dark:text-gray-300">
<span>{typeLabel}</span>
<div className="flex items-center gap-2">
{eventBadge && (
<Badge
variant="outline"
className="rounded-full border-gray-200 bg-gray-100/70 text-gray-700 dark:border-gray-700 dark:bg-gray-800/70 dark:text-gray-100"
>
{eventBadge}
</Badge>
)}
{badgeLabel && (
<Badge
className={cn(
'rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em]',
accent.badge,
)}
>
{badgeLabel}
</Badge>
)}
</div>
</div>
<div className="space-y-2">
<CardTitle className="text-2xl font-semibold text-gray-900 dark:text-gray-50">{pkg.name}</CardTitle>
<CardDescription className="text-sm text-gray-600 dark:text-gray-200">{pkg.description}</CardDescription>
</div>
</CardHeader>
<CardContent className={cn('flex flex-1 flex-col gap-5', compact && 'px-5')}>
<div className="space-y-1">
<div className={cn('flex flex-wrap items-baseline gap-2', compact && 'gap-x-2')}>
<span className={cn('text-3xl font-semibold', accent.price, compact && 'text-2xl')}>{priceLabel}</span>
{pkg.price !== 0 && (
<span className="text-xs text-gray-500 dark:text-gray-300">/ {cadenceLabel}</span>
)}
</div>
<p className="text-[11px] uppercase tracking-[0.3em] text-gray-400 dark:text-gray-400">{typeLabel}</p>
</div>
<Separator className="bg-gray-200/70 dark:bg-gray-800/70" />
<div className="space-y-4">
{metricList}
{featureList}
</div>
</CardContent>
{showCTA && onSelect && (
<CardFooter className={cn('mt-auto flex-col gap-3', compact && 'px-5')}>
<div className="grid w-full gap-2 sm:grid-cols-[7fr_3fr]">
<Button
asChild
className={cn(
'w-full justify-center rounded-full text-sm font-semibold',
'bg-pink-500 text-white hover:bg-pink-600',
compact && 'py-3 text-sm',
)}
variant="default"
>
<Link
href={purchaseUrl}
onClick={() => {
onCtaClick?.(pkg, variant);
localStorage.setItem('preferred_package', JSON.stringify(pkg));
}}
>
{t('packages.to_order')}
<ArrowRight className="h-4 w-4" aria-hidden />
</Link>
</Button>
<Button
onClick={() => onSelect(pkg)}
className={cn(
'w-full justify-center rounded-full text-sm font-semibold',
accent.buttonDefault,
compact && 'py-3 text-sm',
)}
variant="outline"
>
{ctaLabel ?? t('packages.view_details')}
</Button>
</div>
</CardFooter>
)}
</Card>
);
}
interface FeaturedBundleCardProps {
pkg: Package;
onSelect?: (pkg: Package) => void;
onCtaClick?: (pkg: Package, variant: 'reseller') => void;
className?: string;
}
function FeaturedBundleCard({ pkg, onSelect, onCtaClick, className }: FeaturedBundleCardProps) {
const { t } = useTranslation('marketing');
const { t: tCommon } = useTranslation('common');
const { localizedPath } = useLocalizedRoutes();
const accent = getAccentTheme('reseller');
const purchaseUrl = localizedPath(`/bestellen/${pkg.id}`);
const numericPrice = Number(pkg.price);
const priceLabel =
numericPrice === 0
? t('packages.free')
: `${numericPrice.toLocaleString()} ${t('packages.currency.euro')}`;
const cadenceLabel = t('packages.billing_per_kontingent');
const metrics = resolvePackageMetrics(pkg, 'reseller', t, tCommon);
const highlightFeatures = buildDisplayFeatures(pkg, 'reseller').slice(0, 4);
return (
<Card
className={cn(
'relative overflow-hidden rounded-3xl border border-slate-900/60 bg-slate-950/80 text-white shadow-xl',
className,
)}
>
<CardHeader className="gap-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] font-semibold uppercase tracking-[0.25em] text-slate-300">
<span>{t('packages.subscription')}</span>
<Badge
className={cn(
'rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em]',
accent.badge,
)}
>
{t('packages.badge_best_value')}
</Badge>
</div>
<div className="space-y-2">
<CardTitle className="text-2xl font-semibold text-white">{pkg.name}</CardTitle>
<CardDescription className="text-sm text-slate-200">{pkg.description}</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
<p className="text-[11px] uppercase tracking-[0.3em] text-slate-300">{t('packages.price')}</p>
<div className="flex items-baseline gap-2">
<span className={cn('text-3xl font-semibold', accent.price)}>{priceLabel}</span>
<span className="text-xs text-slate-300">/ {cadenceLabel}</span>
</div>
</div>
{pkg.events && (
<Badge variant="outline" className="rounded-full border-white/20 bg-white/10 text-white">
{t('packages.events_badge', { count: pkg.events, defaultValue: `${pkg.events} Events` })}
</Badge>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-slate-300">
{t('packages.limits_label', 'Limits')}
</p>
<ul className="space-y-2 text-xs text-slate-100">
{metrics.map((metric) => (
<li key={metric.key} className="flex items-start gap-2">
<Check className="mt-0.5 h-3.5 w-3.5 text-white" />
<div className="flex flex-wrap items-baseline gap-1">
<span className="text-sm font-semibold text-white">{metric.value}</span>
<span className="text-[11px] uppercase tracking-wide text-slate-300">{metric.label}</span>
</div>
</li>
))}
</ul>
</div>
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-slate-300">
{t('packages.features_label', 'Features')}
</p>
<ul className="space-y-2 text-xs text-slate-100">
{highlightFeatures.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="mt-0.5 h-3.5 w-3.5 text-white" />
<span>{t(`packages.feature_${feature}`)}</span>
</li>
))}
</ul>
</div>
</div>
</CardContent>
{onSelect && (
<CardFooter className="mt-auto flex flex-col gap-3">
<Button asChild className="w-full justify-center rounded-full bg-pink-500 text-sm font-semibold text-white hover:bg-pink-600">
<Link
href={purchaseUrl}
onClick={() => {
onCtaClick?.(pkg, 'reseller');
localStorage.setItem('preferred_package', JSON.stringify(pkg));
}}
>
{t('packages.to_order')}
<ArrowRight className="h-4 w-4" aria-hidden />
</Link>
</Button>
<Button
onClick={() => onSelect(pkg)}
variant="outline"
className={cn(
'w-full justify-center rounded-full text-sm font-semibold text-white',
'border-white/20 bg-white/5 hover:bg-white/10',
)}
>
{t('packages.view_details')}
</Button>
</CardFooter>
)}
</Card>
);
}
interface PackageDetailGridProps {
packageData: Package;
variant: 'endcustomer' | 'reseller';
isHighlight: boolean;
purchaseUrl: string;
onCtaClick: () => void;
t: TFunction;
tCommon: TFunction;
testimonials: { name: string; text: string; rating: number }[];
close: () => void;
}
const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
packageData,
variant,
isHighlight,
purchaseUrl,
onCtaClick,
t,
tCommon,
testimonials,
close,
}) => {
const metrics = resolvePackageMetrics(packageData, variant, t, tCommon);
const highlightFeatures = useMemo(
() => buildDisplayFeatures(packageData, variant).slice(0, 5),
[packageData, variant],
);
return (
<div className="grid gap-8 lg:grid-cols-[320px,1fr]">
<div className="space-y-6">
<div className="rounded-2xl border border-gray-100 bg-gray-50 p-6 dark:border-gray-800 dark:bg-gray-900">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-300">{t('packages.price')}</p>
<p className="text-4xl font-semibold text-gray-900 dark:text-gray-100">
{Number(packageData.price).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
{t('packages.currency.euro')}
</p>
{packageData.price > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-300">
/ {variant === 'reseller' ? t('packages.billing_per_kontingent') : t('packages.billing_per_event')}
</p>
)}
</div>
{isHighlight && (
<span className="rounded-full bg-gray-900 px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-white dark:bg-pink-600">
{variant === 'reseller' ? t('packages.badge_best_value') : t('packages.badge_recommended')}
</span>
)}
</div>
<div className="mt-6 grid grid-cols-2 gap-3 text-sm">
{metrics.map((metric) => (
<div key={metric.key} className="rounded-xl bg-white px-4 py-3 text-center shadow-sm dark:bg-gray-800">
<p className="text-lg font-semibold text-gray-900 dark:text-gray-100">{metric.value}</p>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{metric.label}</p>
</div>
))}
</div>
<Button
asChild
className="mt-6 w-full justify-center rounded-full bg-rose-600/90 py-3 text-base font-semibold text-white shadow-lg shadow-rose-500/30 transition hover:bg-rose-600"
>
<Link
href={purchaseUrl}
onClick={() => {
onCtaClick();
}}
>
{t('packages.to_order')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">{t('packages.order_hint')}</p>
</div>
<div className="rounded-2xl border border-gray-100 bg-white p-6 dark:border-gray-800 dark:bg-gray-900">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{t('packages.feature_highlights')}</h3>
<ul className="mt-4 space-y-3 text-sm text-gray-700 dark:text-gray-200">
{highlightFeatures.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="mt-1 h-4 w-4 text-gray-900 dark:text-gray-100" />
<span>{t(`packages.feature_${feature}`)}</span>
</li>
))}
</ul>
</div>
<div className="rounded-2xl border border-gray-100 bg-white p-6 dark:border-gray-800 dark:bg-gray-900">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{t('packages.more_details_tab')}</h3>
<Tabs defaultValue="breakdown">
<TabsList className="grid grid-cols-2 rounded-full bg-gray-100 p-1 text-sm dark:bg-gray-800">
<TabsTrigger value="breakdown" className="rounded-full">
{t('packages.breakdown_label')}
</TabsTrigger>
<TabsTrigger value="testimonials" className="rounded-full">
{t('packages.testimonials_title')}
</TabsTrigger>
</TabsList>
<TabsContent value="breakdown" className="mt-6">
{packageData.description_breakdown?.length ? (
<Accordion type="multiple" className="space-y-4">
{packageData.description_breakdown.map((entry, index) => (
<AccordionItem key={index} value={`detail-${index}`} className="rounded-2xl border border-gray-100 bg-white px-4 dark:border-gray-800 dark:bg-gray-900">
<AccordionTrigger className="text-left text-base font-medium text-gray-900 hover:no-underline dark:text-gray-100">
{entry.title ?? t('packages.limits_label')}
</AccordionTrigger>
<AccordionContent className="pb-4 text-sm text-gray-600 whitespace-pre-line dark:text-gray-300">
{entry.value}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">{t('packages.breakdown_label_hint')}</p>
)}
</TabsContent>
<TabsContent value="testimonials" className="mt-6">
<div className="space-y-4">
{testimonials.map((testimonial, index) => (
<div key={index} className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{testimonial.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{packageData.name}</p>
</div>
<div className="flex gap-1 text-amber-400">
{[...Array(testimonial.rating)].map((_, i) => (
<Star key={i} className="h-3.5 w-3.5" fill="currentColor" />
))}
</div>
</div>
<p className="mt-3 text-sm text-gray-600 dark:text-gray-300">{testimonial.text}</p>
</div>
))}
<Button variant="outline" className="w-full rounded-full border-gray-200 text-sm font-medium text-gray-700 dark:border-gray-700 dark:text-gray-100" onClick={close}>
{t('packages.close')}
</Button>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
};
return (
<MarketingLayout title={t('packages.title')}>
<section className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 px-4 py-8 md:py-10">
<motion.div
className="container mx-auto text-center space-y-4 md:space-y-6"
variants={stagger}
initial="hidden"
animate="visible"
>
<motion.div className="space-y-2 md:space-y-3" variants={revealUp}>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-600 dark:text-gray-300">
{t('packages.hero_kicker')}
</p>
<h1 className="text-2xl font-bold font-display md:text-4xl">
{t('packages.hero_title')}
</h1>
<p className="mx-auto max-w-2xl font-sans-marketing text-sm text-gray-700 dark:text-gray-200 md:text-lg">
{t('packages.hero_description')}
</p>
</motion.div>
</motion.div>
</section>
<section id="packages-showcase" className="px-4 pt-6 pb-16 md:pt-8 md:pb-20">
<div className="container mx-auto space-y-12">
<Tabs defaultValue="endcustomer" className="space-y-8">
<div className="flex justify-center -mt-5 sm:-mt-6">
<TabsList className="relative z-20 grid w-full max-w-xl grid-cols-2 rounded-full bg-white/90 p-1.5 shadow-lg shadow-black/10 backdrop-blur dark:bg-gray-900/60">
<TabsTrigger
className="rounded-full px-4 py-2 text-sm font-semibold uppercase tracking-[0.2em] text-gray-600 data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow-none dark:text-gray-200 dark:data-[state=active]:bg-white dark:data-[state=active]:text-gray-900"
value="endcustomer"
>
{t('packages.tab_endcustomer')}
</TabsTrigger>
<TabsTrigger
className="rounded-full px-4 py-2 text-sm font-semibold uppercase tracking-[0.2em] text-gray-600 data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow-none dark:text-gray-200 dark:data-[state=active]:bg-white dark:data-[state=active]:text-gray-900"
value="reseller"
>
{t('packages.tab_reseller')}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="endcustomer" className="space-y-8 pt-8">
<div className="md:hidden">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 w-4 bg-gradient-to-r from-white/70 to-transparent dark:from-gray-950/70" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-4 bg-gradient-to-l from-white/70 to-transparent dark:from-gray-950/70" />
<div
ref={mobileEndcustomerRef}
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-6"
style={{ scrollPaddingLeft: '16px', scrollPaddingRight: '16px', scrollBehavior: 'smooth' }}
>
{orderedEndcustomerPackages.map((pkg) => (
<div key={pkg.id} className="snap-center basis-[64vw] shrink-0 sm:basis-[56vw]">
<PackageCard
pkg={pkg}
variant="endcustomer"
highlight={pkg.id === highlightEndcustomerId}
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
onCtaClick={handleCtaClick}
className="h-full"
compact
/>
</div>
))}
</div>
</div>
</div>
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
{orderedEndcustomerPackages.map((pkg) => {
const isHighlight = pkg.id === highlightEndcustomerId;
return (
<motion.div
key={pkg.id}
variants={revealUp}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
<PackageCard
pkg={pkg}
variant="endcustomer"
highlight={isHighlight}
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
onCtaClick={handleCtaClick}
className={cn(
'h-full',
isHighlight && 'md:scale-[1.08] md:-translate-y-6 md:z-10',
)}
compact={!isHighlight}
/>
</motion.div>
);
})}
</div>
<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 pt-8">
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<Card className="rounded-3xl border border-slate-900/40 bg-slate-950/70 text-white shadow-lg">
<CardHeader className="gap-3">
<CardTitle className="text-2xl font-display text-white">{t('packages.bundles_title')}</CardTitle>
<CardDescription className="text-sm text-slate-200">
{t('packages.bundles_description')}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<div className="flex items-center gap-3 rounded-2xl bg-white/10 px-4 py-3">
<LayoutGrid className="h-4 w-4 text-pink-200" />
<p className="text-sm font-semibold text-white">{t('packages.feature_reseller_dashboard')}</p>
</div>
<div className="flex items-center gap-3 rounded-2xl bg-white/10 px-4 py-3">
<Headphones className="h-4 w-4 text-pink-200" />
<p className="text-sm font-semibold text-white">{t('packages.feature_priority_support')}</p>
</div>
</CardContent>
</Card>
</motion.div>
<div className="md:hidden">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 w-4 bg-gradient-to-r from-white/70 to-transparent dark:from-gray-950/70" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-4 bg-gradient-to-l from-white/70 to-transparent dark:from-gray-950/70" />
<div
ref={mobileResellerRef}
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-6"
style={{ scrollPaddingLeft: '16px', scrollPaddingRight: '16px', scrollBehavior: 'smooth' }}
>
{orderedResellerPackages.map((pkg) => (
<div key={pkg.id} className="snap-center basis-[64vw] shrink-0 sm:basis-[56vw]">
<PackageCard
pkg={pkg}
variant="reseller"
highlight={pkg.id === highlightResellerId}
onSelect={(selected) => handleCardClick(selected, 'reseller')}
onCtaClick={handleCtaClick}
className="h-full"
compact
/>
</div>
))}
</div>
</div>
</div>
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
{resellerBundles.map((pkg) => (
<motion.div
key={pkg.id}
variants={revealUp}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
>
<PackageCard
pkg={pkg}
variant="reseller"
highlight={false}
onSelect={(selected) => handleCardClick(selected, 'reseller')}
onCtaClick={handleCtaClick}
className="h-full"
compact
/>
</motion.div>
))}
{highlightResellerPackage && (
<motion.div
key={highlightResellerPackage.id}
variants={revealUp}
initial="hidden"
whileInView="visible"
viewport={viewportOnce}
className="md:col-span-2 xl:col-span-2"
>
<FeaturedBundleCard
pkg={highlightResellerPackage}
onSelect={(selected) => handleCardClick(selected, 'reseller')}
onCtaClick={handleCtaClick}
className="h-full"
/>
</motion.div>
)}
</div>
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
<PackageComparison packages={orderedResellerPackages} variant="reseller" serviceTierNames={serviceTierNames} />
</motion.div>
</TabsContent>
<div className="flex justify-center pt-2 md:pt-6">
<Link
href={localizedPath(locale === 'en' ? '/gift-card' : '/gutschein')}
className="group inline-flex items-center gap-3 rounded-full border border-gray-200 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-gray-700 shadow-sm transition hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-gray-900/80"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-pink-500/15 text-pink-600 dark:text-pink-300">
<Gift className="h-4 w-4" aria-hidden />
</span>
<span>{t('packages.gift_cta')}</span>
<ArrowRight className="h-3.5 w-3.5 text-gray-400 transition group-hover:translate-x-1" aria-hidden />
</Link>
</div>
</Tabs>
</div>
</section>
<section className="px-4 pb-16 md:pb-20">
<div className="container mx-auto space-y-8">
<div className="text-center space-y-3">
<h2 className="text-3xl font-bold font-display">{t('packages.calculator_title')}</h2>
<p className="text-muted-foreground">{t('packages.calculator_description')}</p>
</div>
<Card className="mx-auto max-w-xl rounded-3xl border border-gray-100 bg-white/90 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<CardHeader className="gap-2 text-center">
<CardTitle className="text-xl font-display text-gray-900 dark:text-gray-50">
{t('packages.calculator_question')}
</CardTitle>
<CardDescription className="text-sm text-gray-500 dark:text-gray-300">
{t('packages.calculator_hint')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-[0.25em] text-gray-400">
<span>{t('packages.calculator_min_label')}</span>
<span>{t('packages.calculator_max_label', { count: guestSliderMax.toLocaleString() })}</span>
</div>
<input
type="range"
min={guestSliderMin}
max={guestSliderMax}
step={5}
value={guestCount}
onChange={(event) => setGuestCount(Number(event.target.value))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-pink-500 dark:bg-gray-800"
/>
<div className="flex items-center justify-between text-sm text-gray-600 dark:text-gray-300">
<span>{t('packages.max_guests_label')}</span>
<span className="text-base font-semibold text-gray-900 dark:text-gray-50">
{guestCount.toLocaleString()} {t('packages.max_guests')}
</span>
</div>
</div>
{recommendedPackage && (() => {
const limit = recommendedPackage.limits?.max_guests ?? recommendedPackage.max_guests ?? null;
const hasLimit = typeof limit === 'number' && Number.isFinite(limit);
const limitLabel = hasLimit ? limit.toLocaleString() : tCommon('unlimited');
const priceLabel =
Number(recommendedPackage.price) === 0
? t('packages.free')
: `${Number(recommendedPackage.price).toLocaleString()} ${t('packages.currency.euro')}`;
return (
<div className="rounded-2xl border border-gray-100 bg-gray-50 px-4 py-4 text-center dark:border-gray-800 dark:bg-gray-950/60">
<p className="text-[11px] font-semibold uppercase tracking-[0.25em] text-gray-400">
{t('packages.calculator_recommendation')}
</p>
<div className="mt-2 flex flex-col items-center gap-2">
<p className="text-xl font-semibold text-gray-900 dark:text-gray-50">
{recommendedPackage.name}
</p>
<p className="text-sm text-gray-500 dark:text-gray-300">
{priceLabel} · {hasLimit
? t('packages.calculator_recommendation_hint', { count: limitLabel })
: t('packages.calculator_recommendation_hint_unlimited')}
</p>
<Button
variant="outline"
className="rounded-full border-gray-200 text-sm font-semibold text-gray-700 hover:bg-white dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
onClick={() => handleCardClick(recommendedPackage, 'endcustomer')}
>
{t('packages.view_details')}
</Button>
</div>
</div>
);
})()}
</CardContent>
</Card>
</div>
</section>
<section className="py-20 px-4 bg-muted/30 dark:bg-gray-900">
<div className="container mx-auto space-y-10">
<div className="text-center space-y-3">
<h2 className="text-3xl font-bold font-display">{t('packages.faq_title')}</h2>
<p className="text-muted-foreground">{t('packages.faq_lead')}</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{[
{ title: t('packages.faq_branding'), body: t('packages.faq_branding_desc') },
{ title: t('packages.faq_upgrade'), body: t('packages.faq_upgrade_desc') },
{ title: t('packages.faq_reseller'), body: t('packages.faq_reseller_desc') },
{ title: t('packages.faq_payment'), body: t('packages.faq_payment_desc') },
{ title: t('packages.faq_photobooth'), body: t('packages.faq_photobooth_desc') },
].map((item) => (
<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>
</section>
{/* Details overlay */}
{selectedPackage && (
isMobile ? (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="bottom"
className="h-[90vh] overflow-hidden rounded-t-[32px] border border-gray-200 bg-white p-0 dark:border-gray-800 dark:bg-gray-900"
onOpenAutoFocus={handleDetailAutoFocus}
>
{renderDetailBody('h-full overflow-y-auto space-y-8 p-6')}
</SheetContent>
</Sheet>
) : (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="max-w-6xl border border-gray-100 bg-white px-0 py-0 sm:rounded-[32px] dark:border-gray-800 dark:bg-gray-900"
onOpenAutoFocus={handleDetailAutoFocus}
>
{renderDetailBody('max-h-[88vh] overflow-y-auto space-y-8 p-6 md:p-10')}
</DialogContent>
</Dialog>
)
)} {/* Testimonials Section entfernt, da nun im Dialog */}
</MarketingLayout>
);
};
const handleDetailAutoFocus = (event: Event) => {
event.preventDefault();
// Guard in case refs are not in scope when autofocusing
if (typeof dialogScrollRef !== 'undefined') {
dialogScrollRef.current?.scrollTo({ top: 0 });
}
if (typeof dialogHeadingRef !== 'undefined') {
dialogHeadingRef.current?.focus();
}
};
Packages.layout = (page: React.ReactNode) => page;
export default Packages;