1267 lines
47 KiB
TypeScript
1267 lines
47 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 { 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 { cn } from '@/lib/utils';
|
||
import MarketingLayout from '@/layouts/mainWebsite';
|
||
import { useAnalytics } from '@/hooks/useAnalytics';
|
||
import { useCtaExperiment } from '@/hooks/useCtaExperiment';
|
||
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;
|
||
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');
|
||
}
|
||
}
|
||
|
||
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: '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'),
|
||
},
|
||
];
|
||
|
||
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 {
|
||
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);
|
||
}
|
||
}, [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],
|
||
);
|
||
|
||
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: 'bg-amber-50 text-amber-700 dark:bg-amber-500/20 dark:text-amber-100',
|
||
price: 'text-amber-600 dark:text-amber-100',
|
||
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/50 dark:text-amber-100 dark:hover:bg-amber-500/10',
|
||
cardBorder: 'border border-amber-100 dark:border-amber-500/40',
|
||
highlightShadow: 'shadow-lg shadow-amber-100/60 bg-gradient-to-br from-amber-50/70 via-white to-amber-100/60 dark:shadow-amber-900/40 dark:from-amber-900/40 dark:via-gray-900 dark:to-amber-900/20',
|
||
}
|
||
: {
|
||
badge: 'bg-rose-50 text-rose-700 dark:bg-pink-500/20 dark:text-pink-100',
|
||
price: 'text-rose-600 dark:text-pink-100',
|
||
buttonHighlight: 'bg-gray-900 text-white hover:bg-gray-800 dark:bg-pink-600 dark:hover:bg-pink-500',
|
||
buttonDefault: 'border border-rose-100 text-rose-700 hover:bg-rose-50 dark:border-pink-500/50 dark:text-pink-100 dark:hover:bg-pink-500/10',
|
||
cardBorder: 'border border-rose-100 dark:border-pink-500/40',
|
||
highlightShadow: 'shadow-lg shadow-rose-100/60 bg-gradient-to-br from-rose-50/70 via-white to-rose-100/60 dark:shadow-pink-900/40 dark:from-pink-900/40 dark:via-gray-900 dark:to-pink-900/10',
|
||
};
|
||
|
||
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 'Standard';
|
||
}
|
||
|
||
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_most_popular'))
|
||
: pkg.price === 0
|
||
? t('packages.badge_starter')
|
||
: null;
|
||
|
||
const displayFeatures = buildDisplayFeatures(pkg, variant);
|
||
const keyFeatures = displayFeatures.slice(0, 3);
|
||
const visibleFeatures = compact ? displayFeatures.slice(0, 3) : displayFeatures.slice(0, 5);
|
||
const metrics = resolvePackageMetrics(pkg, variant, t, tCommon);
|
||
|
||
const metricList = compact ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{metrics.map((metric) => (
|
||
<div key={metric.key} className="rounded-full border border-gray-200 px-3 py-1 text-xs font-semibold text-gray-700 dark:border-gray-700 dark:text-gray-100">
|
||
<span className="text-[11px] font-medium uppercase text-gray-400 dark:text-gray-400">{metric.label}</span>
|
||
<span className="ml-1 text-gray-900 dark:text-gray-100">{metric.value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||
{metrics.map((metric) => (
|
||
<div key={metric.key} className="rounded-xl bg-gray-50 px-4 py-3 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>
|
||
);
|
||
|
||
const featureList = compact ? (
|
||
<ul className="space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||
{visibleFeatures.map((feature) => (
|
||
<li key={feature} className="flex items-start gap-2 text-xs">
|
||
<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>
|
||
) : (
|
||
<ul className="space-y-2 text-sm text-gray-700 dark:text-gray-200">
|
||
{visibleFeatures.map((feature) => (
|
||
<li key={feature} className="flex items-center gap-2">
|
||
<Check className="h-4 w-4 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-2xl border border-gray-100 bg-white shadow-sm transition hover:shadow-lg dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50',
|
||
compact && 'p-3',
|
||
highlight && `${accent.cardBorder} ${accent.highlightShadow}`,
|
||
className,
|
||
)}
|
||
>
|
||
<CardHeader className={cn('gap-4', compact && 'gap-3 p-0')}>
|
||
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-300">
|
||
<span>{typeLabel}</span>
|
||
{badgeLabel && (
|
||
<span
|
||
className={cn(
|
||
'rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-wider',
|
||
accent.badge,
|
||
)}
|
||
>
|
||
{badgeLabel}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="space-y-2">
|
||
<CardTitle className="text-2xl 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-col gap-6', compact && 'gap-4 p-0 pt-2')}>
|
||
<div>
|
||
<div className={cn('flex items-baseline gap-2', compact && 'flex-wrap text-balance')}>
|
||
<span className={cn('text-4xl font-semibold', accent.price, compact && 'text-3xl')}>{priceLabel}</span>
|
||
{pkg.price !== 0 && (
|
||
<span className="text-sm text-gray-500 dark:text-gray-300">/ {cadenceLabel}</span>
|
||
)}
|
||
</div>
|
||
{variant === 'endcustomer' && (
|
||
<p className="text-xs text-gray-400 dark:text-gray-300">
|
||
{pkg.events} × {t('packages.one_time')}
|
||
</p>
|
||
)}
|
||
</div>
|
||
{metricList}
|
||
{featureList}
|
||
</CardContent>
|
||
{showCTA && onSelect && (
|
||
<CardFooter className={cn('mt-auto', compact && 'pt-4')}>
|
||
<div className="grid w-full grid-cols-[7fr_3fr] gap-2">
|
||
<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-4 text-base',
|
||
)}
|
||
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-4 text-base',
|
||
)}
|
||
variant="outline"
|
||
>
|
||
{ctaLabel ?? t('packages.view_details')}
|
||
</Button>
|
||
</div>
|
||
</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_most_popular')}
|
||
</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-12 md:py-16">
|
||
<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>
|
||
<h1 className="text-3xl font-bold font-display md:text-5xl">
|
||
{t('packages.hero_title')}
|
||
</h1>
|
||
<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>
|
||
</motion.div>
|
||
<motion.div className="flex flex-wrap items-center justify-center gap-3" variants={revealUp}>
|
||
<Button
|
||
asChild
|
||
size="lg"
|
||
className="rounded-full bg-gray-900 text-white shadow-lg shadow-gray-900/20 hover:bg-gray-800"
|
||
onClick={() => {
|
||
trackPackagesHeroClick();
|
||
trackEvent({
|
||
category: 'marketing_packages',
|
||
action: 'hero_cta',
|
||
name: `scroll:${packagesHeroVariant}`,
|
||
});
|
||
}}
|
||
>
|
||
<a href="#packages-showcase">
|
||
{t('packages.cta_explore')}
|
||
<ArrowRight className="ml-2 inline h-4 w-4" aria-hidden />
|
||
</a>
|
||
</Button>
|
||
<Button
|
||
asChild
|
||
size="lg"
|
||
variant="outline"
|
||
className="rounded-full border-white/40 bg-white/30 text-gray-900 backdrop-blur hover:bg-white/50 dark:border-gray-800 dark:bg-gray-900/40 dark:text-gray-100"
|
||
>
|
||
<Link href={localizedPath('/kontakt')}>
|
||
{t('packages.contact_us')}
|
||
</Link>
|
||
</Button>
|
||
<Button
|
||
asChild
|
||
size="lg"
|
||
variant="ghost"
|
||
className="rounded-full text-gray-900 hover:bg-white/60 dark:text-gray-100 dark:hover:bg-gray-800/70"
|
||
>
|
||
<Link href={localizedPath(locale === 'en' ? '/gift-card' : '/gutschein')}>
|
||
{t('packages.gift_cta', 'Paket verschenken')}
|
||
</Link>
|
||
</Button>
|
||
</motion.div>
|
||
<motion.p className="text-sm text-gray-600 dark:text-gray-300" variants={revealUp}>
|
||
{t('packages.hero_secondary')}
|
||
</motion.p>
|
||
</motion.div>
|
||
</section>
|
||
|
||
<section id="packages-showcase" className="px-4 py-16 md:py-20">
|
||
<div className="container mx-auto space-y-12">
|
||
<Tabs defaultValue="endcustomer" className="space-y-8">
|
||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||
<TabsList className="grid w-full max-w-2xl grid-cols-2 rounded-full bg-white/70 p-1 shadow-sm dark:bg-gray-800/70">
|
||
<TabsTrigger className="rounded-full font-semibold" value="endcustomer">
|
||
{t('packages.tab_endcustomer')}
|
||
</TabsTrigger>
|
||
<TabsTrigger className="rounded-full font-semibold" value="reseller">
|
||
{t('packages.tab_reseller')}
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
<p className="text-sm text-muted-foreground max-w-md">{t('packages.comparison_hint')}</p>
|
||
</div>
|
||
|
||
<TabsContent value="endcustomer" className="space-y-8">
|
||
<div className="md:hidden">
|
||
<div className="relative">
|
||
<div className="pointer-events-none absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-white to-transparent dark:from-gray-950" />
|
||
<div className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-white to-transparent dark:from-gray-950" />
|
||
<div
|
||
ref={mobileEndcustomerRef}
|
||
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-6"
|
||
style={{ scrollPaddingLeft: '16px', scrollBehavior: 'smooth' }}
|
||
>
|
||
{orderedEndcustomerPackages.map((pkg) => (
|
||
<div key={pkg.id} className="snap-start basis-[72vw] shrink-0 sm:basis-[60vw]">
|
||
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||
<PackageCard
|
||
pkg={pkg}
|
||
variant="endcustomer"
|
||
highlight={pkg.id === highlightEndcustomerId}
|
||
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
|
||
onCtaClick={handleCtaClick}
|
||
className="h-full"
|
||
compact
|
||
/>
|
||
</motion.div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
|
||
{orderedEndcustomerPackages.map((pkg) => (
|
||
<motion.div
|
||
key={pkg.id}
|
||
variants={revealUp}
|
||
initial="hidden"
|
||
whileInView="visible"
|
||
viewport={viewportOnce}
|
||
>
|
||
<PackageCard
|
||
pkg={pkg}
|
||
variant="endcustomer"
|
||
highlight={pkg.id === highlightEndcustomerId}
|
||
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
|
||
onCtaClick={handleCtaClick}
|
||
className="h-full"
|
||
compact
|
||
/>
|
||
</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">
|
||
<div className="md:hidden">
|
||
<div className="relative">
|
||
<div className="pointer-events-none absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-white to-transparent dark:from-gray-950" />
|
||
<div className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-white to-transparent dark:from-gray-950" />
|
||
<div
|
||
ref={mobileResellerRef}
|
||
className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-6"
|
||
style={{ scrollPaddingLeft: '16px', scrollBehavior: 'smooth' }}
|
||
>
|
||
{orderedResellerPackages.map((pkg) => (
|
||
<div key={pkg.id} className="snap-start basis-[72vw] shrink-0 sm:basis-[60vw]">
|
||
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||
<PackageCard
|
||
pkg={pkg}
|
||
variant="reseller"
|
||
highlight={pkg.id === highlightResellerId}
|
||
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
||
onCtaClick={handleCtaClick}
|
||
className="h-full"
|
||
compact
|
||
/>
|
||
</motion.div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
|
||
{orderedResellerPackages.map((pkg) => (
|
||
<motion.div
|
||
key={pkg.id}
|
||
variants={revealUp}
|
||
initial="hidden"
|
||
whileInView="visible"
|
||
viewport={viewportOnce}
|
||
>
|
||
<PackageCard
|
||
pkg={pkg}
|
||
variant="reseller"
|
||
highlight={pkg.id === highlightResellerId}
|
||
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
||
onCtaClick={handleCtaClick}
|
||
className="h-full"
|
||
compact
|
||
/>
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||
<PackageComparison packages={orderedResellerPackages} variant="reseller" serviceTierNames={serviceTierNames} />
|
||
</motion.div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</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') },
|
||
].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;
|