Files
fotospiel-app/resources/js/pages/marketing/Packages.tsx

1057 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useMemo, useRef, useLayoutEffect } from 'react';
import { Link } 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 { ArrowRight, Check, Star } from 'lucide-react';
interface Package {
id: number;
name: string;
slug: string;
description: string;
description_breakdown: DescriptionEntry[];
gallery_duration_label?: string;
price: number;
events: number | null;
features: string[];
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;
}
const sortPackagesByPrice = (packages: Package[]): Package[] =>
[...packages].sort((a, b) => Number(a.price ?? 0) - Number(b.price ?? 0));
interface PackageComparisonProps {
packages: Package[];
variant: 'endcustomer' | 'reseller';
}
const buildDisplayFeatures = (pkg: Package): 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 (pkg.watermark_allowed === false) {
removeFeature('watermark');
addFeature('no_watermark');
} else {
removeFeature('no_watermark');
addFeature('watermark');
}
if (pkg.branding_allowed) {
addFeature('custom_branding');
} else {
removeFeature('custom_branding');
}
return Array.from(new Set(features));
};
function PackageComparison({ packages, variant }: 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_year')}`,
},
{
key: 'max_tenants',
label: t('packages.max_tenants'),
value: (pkg: Package) => pkg.limits?.max_tenants?.toLocaleString() ?? tCommon('unlimited'),
},
{
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 = [
{
key: 'watermark',
label: t('packages.watermark_label'),
value: (pkg: Package) =>
pkg.watermark_allowed === false ? t('packages.no_watermark') : t('packages.feature_watermark'),
},
{
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'),
},
];
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 [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 { t } = useTranslation('marketing');
const { t: tCommon } = useTranslation('common');
const {
variant: packagesHeroVariant,
trackClick: trackPackagesHeroClick,
} = useCtaExperiment('packages_hero_cta');
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 testimonials = [
{ name: tCommon('testimonials.anna.name'), text: t('packages.testimonials.anna'), rating: 5 },
{ name: tCommon('testimonials.max.name'), text: t('packages.testimonials.max'), rating: 5 },
{ name: tCommon('testimonials.lisa.name'), text: t('packages.testimonials.lisa'), rating: 5 },
];
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 ? `/purchase-wizard/${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',
price: 'text-amber-600',
buttonHighlight: 'bg-gray-900 text-white hover:bg-gray-800',
buttonDefault: 'border border-amber-200 text-amber-700 hover:bg-amber-50',
cardBorder: 'border border-amber-100',
highlightShadow: 'shadow-lg shadow-amber-100/60 bg-gradient-to-br from-amber-50/70 via-white to-amber-100/60',
}
: {
badge: 'bg-rose-50 text-rose-700',
price: 'text-rose-600',
buttonHighlight: 'bg-gray-900 text-white hover:bg-gray-800',
buttonDefault: 'border border-rose-100 text-rose-700 hover:bg-rose-50',
cardBorder: 'border border-rose-100',
highlightShadow: 'shadow-lg shadow-rose-100/60 bg-gradient-to-br from-rose-50/70 via-white to-rose-100/60',
};
type PackageMetric = {
key: string;
label: string;
value: string;
};
const resolvePackageMetrics = (
pkg: Package,
variant: 'endcustomer' | 'reseller',
t: TFunction,
tCommon: TFunction,
): PackageMetric[] => {
if (variant === 'reseller') {
return [
{
key: 'max_tenants',
label: t('packages.max_tenants'),
value: pkg.limits?.max_tenants
? pkg.limits.max_tenants.toLocaleString()
: 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: 'branding',
label: t('packages.feature_custom_branding'),
value: pkg.branding_allowed ? tCommon('included') : t('packages.feature_no_branding'),
},
];
}
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;
className?: string;
showCTA?: boolean;
ctaLabel?: string;
compact?: boolean;
}
function PackageCard({
pkg,
variant,
highlight = false,
onSelect,
className,
showCTA = true,
ctaLabel,
compact = false,
}: PackageCardProps) {
const { t } = useTranslation('marketing');
const { t: tCommon } = useTranslation('common');
const accent = getAccentTheme(variant);
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_year')
: 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);
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">
<span className="text-[11px] font-medium uppercase text-gray-400">{metric.label}</span>
<span className="ml-1 text-gray-900">{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">
<p className="text-lg font-semibold text-gray-900">{metric.value}</p>
<p className="text-xs uppercase tracking-wide text-gray-500">{metric.label}</p>
</div>
))}
</div>
);
const featureList = compact ? (
<ul className="space-y-1 text-sm text-gray-700">
{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" />
<span>{t(`packages.feature_${feature}`)}</span>
</li>
))}
</ul>
) : (
<ul className="space-y-2 text-sm text-gray-700">
{visibleFeatures.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<Check className="h-4 w-4 text-gray-900" />
<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',
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">
<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">{pkg.name}</CardTitle>
<CardDescription className="text-sm text-gray-600">{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">/ {cadenceLabel}</span>
)}
</div>
{variant === 'endcustomer' && (
<p className="text-xs text-gray-400">
{pkg.events} × {t('packages.one_time')}
</p>
)}
</div>
{metricList}
{featureList}
</CardContent>
{showCTA && onSelect && (
<CardFooter className={cn('mt-auto', compact && 'pt-4')}>
<Button
onClick={() => onSelect(pkg)}
className={cn(
'w-full justify-center rounded-full text-sm font-semibold',
highlight ? accent.buttonHighlight : accent.buttonDefault,
compact && 'py-4 text-base',
)}
variant={highlight ? 'default' : 'outline'}
>
{ctaLabel ?? t('packages.view_details')}
<ArrowRight className="h-4 w-4" aria-hidden />
</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).slice(0, 5),
[packageData],
);
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">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-gray-500">{t('packages.price')}</p>
<p className="text-4xl font-semibold text-gray-900">
{Number(packageData.price).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
{t('packages.currency.euro')}
</p>
{packageData.price > 0 && (
<p className="text-sm text-gray-500">
/ {variant === 'reseller' ? t('packages.billing_per_year') : 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">
{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">
<p className="text-lg font-semibold text-gray-900">{metric.value}</p>
<p className="text-xs uppercase tracking-wide text-gray-500">{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">{t('packages.order_hint')}</p>
</div>
<div className="rounded-2xl border border-gray-100 bg-white p-6">
<h3 className="text-lg font-semibold text-gray-900">{t('packages.feature_highlights')}</h3>
<ul className="mt-4 space-y-3 text-sm text-gray-700">
{highlightFeatures.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="mt-1 h-4 w-4 text-gray-900" />
<span>{t(`packages.feature_${feature}`)}</span>
</li>
))}
</ul>
</div>
<div className="rounded-2xl border border-gray-100 bg-white p-6">
<h3 className="text-lg font-semibold text-gray-900">{t('packages.more_details_tab')}</h3>
<Tabs defaultValue="breakdown">
<TabsList className="grid grid-cols-2 rounded-full bg-gray-100 p-1 text-sm">
<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">
<AccordionTrigger className="text-left text-base font-medium text-gray-900 hover:no-underline">
{entry.title ?? t('packages.limits_label')}
</AccordionTrigger>
<AccordionContent className="pb-4 text-sm text-gray-600 whitespace-pre-line">
{entry.value}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
) : (
<p className="text-sm text-gray-500">{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">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-900">{testimonial.name}</p>
<p className="text-xs text-gray-500">{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">{testimonial.text}</p>
</div>
))}
<Button variant="outline" className="w-full rounded-full border-gray-200 text-sm font-medium text-gray-700" 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">
<div className="container mx-auto text-center space-y-6 md:space-y-8">
<div className="space-y-3 md:space-y-4">
<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>
</div>
<div className="flex flex-wrap items-center justify-center gap-3">
<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>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">
{t('packages.hero_secondary')}
</p>
</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]">
<PackageCard
pkg={pkg}
variant="endcustomer"
highlight={pkg.id === highlightEndcustomerId}
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
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) => (
<PackageCard
key={pkg.id}
pkg={pkg}
variant="endcustomer"
highlight={pkg.id === highlightEndcustomerId}
onSelect={(selected) => handleCardClick(selected, 'endcustomer')}
className="h-full"
compact
/>
))}
</div>
<PackageComparison packages={orderedEndcustomerPackages} variant="endcustomer" />
</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]">
<PackageCard
pkg={pkg}
variant="reseller"
highlight={pkg.id === highlightResellerId}
onSelect={(selected) => handleCardClick(selected, 'reseller')}
className="h-full"
compact
/>
</div>
))}
</div>
</div>
</div>
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
{orderedResellerPackages.map((pkg) => (
<PackageCard
key={pkg.id}
pkg={pkg}
variant="reseller"
highlight={pkg.id === highlightResellerId}
onSelect={(selected) => handleCardClick(selected, 'reseller')}
className="h-full"
compact
/>
))}
</div>
<PackageComparison packages={orderedResellerPackages} variant="reseller" />
</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_free'), body: t('packages.faq_free_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) => (
<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>
))}
</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"
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]"
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();
dialogScrollRef.current?.scrollTo({ top: 0 });
dialogHeadingRef.current?.focus();
};
Packages.layout = (page: React.ReactNode) => page;
export default Packages;