Files
fotospiel-app/resources/js/pages/marketing/Packages.tsx
Codex Agent ad829ae509
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Update partner packages, copy, and demo switcher
2026-01-15 17:33:36 +01:00

1160 lines
43 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, 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';
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';
}
return pkg.watermark_allowed === false ? 'no_watermark' : '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 {
variant: packagesHeroVariant,
trackClick: trackPackagesHeroClick,
} = useCtaExperiment('packages_hero_cta');
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 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 ? 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;
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_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')}>
<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, 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">
<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>
<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>
</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" serviceTierNames={serviceTierNames} />
</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 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;