Redesign marketing packages layout
This commit is contained in:
@@ -6,16 +6,18 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import MarketingLayout from '@/layouts/mainWebsite';
|
import MarketingLayout from '@/layouts/mainWebsite';
|
||||||
import { useAnalytics } from '@/hooks/useAnalytics';
|
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||||
import { useCtaExperiment } from '@/hooks/useCtaExperiment';
|
import { useCtaExperiment } from '@/hooks/useCtaExperiment';
|
||||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||||
import { useLocale } from '@/hooks/useLocale';
|
import { useLocale } from '@/hooks/useLocale';
|
||||||
import { ArrowRight, Check, Star } from 'lucide-react';
|
import { ArrowRight, Check, Headphones, LayoutGrid, Star } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { motion, useReducedMotion } from 'framer-motion';
|
import { motion, useReducedMotion } from 'framer-motion';
|
||||||
|
|
||||||
@@ -404,6 +406,57 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
[resellerPackages],
|
[resellerPackages],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const highlightResellerPackage = useMemo(
|
||||||
|
() => orderedResellerPackages.find((pkg) => pkg.id === highlightResellerId) ?? null,
|
||||||
|
[orderedResellerPackages, highlightResellerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resellerBundles = useMemo(
|
||||||
|
() => orderedResellerPackages.filter((pkg) => pkg.id !== highlightResellerId),
|
||||||
|
[orderedResellerPackages, highlightResellerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const guestSliderMin = 1;
|
||||||
|
const guestSliderMax = useMemo(() => {
|
||||||
|
const limits = orderedEndcustomerPackages
|
||||||
|
.map((pkg) => pkg.limits?.max_guests ?? pkg.max_guests)
|
||||||
|
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value));
|
||||||
|
|
||||||
|
if (limits.length === 0) {
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(500, ...limits);
|
||||||
|
}, [orderedEndcustomerPackages]);
|
||||||
|
|
||||||
|
const [guestCount, setGuestCount] = useState(() => Math.min(100, guestSliderMax));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setGuestCount((current) => {
|
||||||
|
if (current < guestSliderMin) {
|
||||||
|
return guestSliderMin;
|
||||||
|
}
|
||||||
|
if (current > guestSliderMax) {
|
||||||
|
return guestSliderMax;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
}, [guestSliderMax, guestSliderMin]);
|
||||||
|
|
||||||
|
const recommendedPackage = useMemo(() => {
|
||||||
|
if (orderedEndcustomerPackages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = orderedEndcustomerPackages.find((pkg) => {
|
||||||
|
const limit = pkg.limits?.max_guests ?? pkg.max_guests;
|
||||||
|
const limitValue = typeof limit === 'number' ? limit : Number.POSITIVE_INFINITY;
|
||||||
|
return limitValue >= guestCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
return match ?? orderedEndcustomerPackages[orderedEndcustomerPackages.length - 1];
|
||||||
|
}, [orderedEndcustomerPackages, guestCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -584,20 +637,20 @@ function selectHighlightPackageId(packages: Package[]): number | null {
|
|||||||
const getAccentTheme = (variant: 'endcustomer' | 'reseller') =>
|
const getAccentTheme = (variant: 'endcustomer' | 'reseller') =>
|
||||||
variant === 'reseller'
|
variant === 'reseller'
|
||||||
? {
|
? {
|
||||||
badge: 'bg-amber-50 text-amber-700 dark:bg-amber-500/20 dark:text-amber-100',
|
badge: 'border border-amber-200/70 bg-amber-100 text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/20 dark:text-amber-100',
|
||||||
price: 'text-amber-600 dark:text-amber-100',
|
price: 'text-amber-600 dark:text-amber-200',
|
||||||
buttonHighlight: 'bg-gray-900 text-white hover:bg-gray-800 dark:bg-amber-600 dark:hover:bg-amber-500',
|
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',
|
buttonDefault: 'border border-amber-200 text-amber-700 hover:bg-amber-50 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10',
|
||||||
cardBorder: 'border border-amber-100 dark:border-amber-500/40',
|
cardBorder: 'border-amber-200/70 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',
|
highlightShadow: 'shadow-xl shadow-amber-200/40 ring-1 ring-amber-200/70 dark:shadow-amber-900/40 dark:ring-amber-500/30',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
badge: 'bg-rose-50 text-rose-700 dark:bg-pink-500/20 dark:text-pink-100',
|
badge: 'border border-rose-200/70 bg-rose-100 text-rose-700 dark:border-pink-500/40 dark:bg-pink-500/20 dark:text-pink-100',
|
||||||
price: 'text-rose-600 dark:text-pink-100',
|
price: 'text-rose-600 dark:text-pink-200',
|
||||||
buttonHighlight: 'bg-gray-900 text-white hover:bg-gray-800 dark:bg-pink-600 dark:hover:bg-pink-500',
|
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',
|
buttonDefault: 'border border-rose-200 text-rose-700 hover:bg-rose-50 dark:border-pink-500/40 dark:text-pink-100 dark:hover:bg-pink-500/10',
|
||||||
cardBorder: 'border border-rose-100 dark:border-pink-500/40',
|
cardBorder: 'border-rose-200/70 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',
|
highlightShadow: 'shadow-xl shadow-rose-200/40 ring-1 ring-rose-200/70 dark:shadow-pink-900/40 dark:ring-pink-500/30',
|
||||||
};
|
};
|
||||||
|
|
||||||
type PackageMetric = {
|
type PackageMetric = {
|
||||||
@@ -726,50 +779,38 @@ function PackageCard({
|
|||||||
const badgeLabel = highlight
|
const badgeLabel = highlight
|
||||||
? (variant === 'reseller'
|
? (variant === 'reseller'
|
||||||
? t('packages.badge_best_value')
|
? t('packages.badge_best_value')
|
||||||
: t('packages.badge_most_popular'))
|
: t('packages.badge_recommended'))
|
||||||
: pkg.price === 0
|
: pkg.price === 0
|
||||||
? t('packages.badge_starter')
|
? t('packages.badge_starter')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const eventBadge = variant === 'reseller' && pkg.events
|
||||||
|
? t('packages.events_badge', { count: pkg.events, defaultValue: `${pkg.events} Events` })
|
||||||
|
: null;
|
||||||
|
|
||||||
const displayFeatures = buildDisplayFeatures(pkg, variant);
|
const displayFeatures = buildDisplayFeatures(pkg, variant);
|
||||||
const keyFeatures = displayFeatures.slice(0, 3);
|
|
||||||
const visibleFeatures = compact ? displayFeatures.slice(0, 3) : displayFeatures.slice(0, 5);
|
const visibleFeatures = compact ? displayFeatures.slice(0, 3) : displayFeatures.slice(0, 5);
|
||||||
const metrics = resolvePackageMetrics(pkg, variant, t, tCommon);
|
const metrics = resolvePackageMetrics(pkg, variant, t, tCommon);
|
||||||
|
|
||||||
const metricList = compact ? (
|
const metricList = (
|
||||||
<div className="flex flex-wrap gap-2">
|
<ul className="space-y-2 text-xs text-gray-700 dark:text-gray-200">
|
||||||
{metrics.map((metric) => (
|
{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">
|
<li key={metric.key} className="flex items-start gap-2">
|
||||||
<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" />
|
<Check className="mt-0.5 h-3.5 w-3.5 text-gray-900 dark:text-gray-100" />
|
||||||
<span>{t(`packages.feature_${feature}`)}</span>
|
<div className="flex flex-wrap items-baseline gap-1">
|
||||||
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{metric.value}</span>
|
||||||
|
<span className="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400">{metric.label}</span>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
);
|
||||||
<ul className="space-y-2 text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
|
const featureList = (
|
||||||
|
<ul className="space-y-2 text-xs text-gray-700 dark:text-gray-200">
|
||||||
{visibleFeatures.map((feature) => (
|
{visibleFeatures.map((feature) => (
|
||||||
<li key={feature} className="flex items-center gap-2">
|
<li key={feature} className="flex items-start gap-2">
|
||||||
<Check className="h-4 w-4 text-gray-900 dark:text-gray-100" />
|
<Check className="mt-0.5 h-3.5 w-3.5 text-gray-900 dark:text-gray-100" />
|
||||||
<span>{t(`packages.feature_${feature}`)}</span>
|
<span>{t(`packages.feature_${feature}`)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -779,57 +820,66 @@ function PackageCard({
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
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',
|
'flex h-full flex-col rounded-3xl border border-gray-100 bg-white text-gray-900 shadow-sm transition hover:shadow-lg dark:border-gray-800 dark:bg-gray-900/90 dark:text-gray-50',
|
||||||
compact && 'p-3',
|
compact && 'gap-4 py-5',
|
||||||
highlight && `${accent.cardBorder} ${accent.highlightShadow}`,
|
highlight && `${accent.cardBorder} ${accent.highlightShadow} md:-translate-y-2 md:scale-[1.02]`,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader className={cn('gap-4', compact && 'gap-3 p-0')}>
|
<CardHeader className={cn('gap-3', compact && 'px-5')}>
|
||||||
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-300">
|
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] font-semibold uppercase tracking-[0.25em] text-gray-500 dark:text-gray-300">
|
||||||
<span>{typeLabel}</span>
|
<span>{typeLabel}</span>
|
||||||
{badgeLabel && (
|
<div className="flex items-center gap-2">
|
||||||
<span
|
{eventBadge && (
|
||||||
className={cn(
|
<Badge
|
||||||
'rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-wider',
|
variant="outline"
|
||||||
accent.badge,
|
className="rounded-full border-gray-200 bg-gray-100/70 text-gray-700 dark:border-gray-700 dark:bg-gray-800/70 dark:text-gray-100"
|
||||||
)}
|
>
|
||||||
>
|
{eventBadge}
|
||||||
{badgeLabel}
|
</Badge>
|
||||||
</span>
|
)}
|
||||||
)}
|
{badgeLabel && (
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em]',
|
||||||
|
accent.badge,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{badgeLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<CardTitle className="text-2xl text-gray-900 dark:text-gray-50">{pkg.name}</CardTitle>
|
<CardTitle className="text-2xl font-semibold text-gray-900 dark:text-gray-50">{pkg.name}</CardTitle>
|
||||||
<CardDescription className="text-sm text-gray-600 dark:text-gray-200">{pkg.description}</CardDescription>
|
<CardDescription className="text-sm text-gray-600 dark:text-gray-200">{pkg.description}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className={cn('flex flex-col gap-6', compact && 'gap-4 p-0 pt-2')}>
|
<CardContent className={cn('flex flex-1 flex-col gap-5', compact && 'px-5')}>
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<div className={cn('flex items-baseline gap-2', compact && 'flex-wrap text-balance')}>
|
<div className={cn('flex flex-wrap items-baseline gap-2', compact && 'gap-x-2')}>
|
||||||
<span className={cn('text-4xl font-semibold', accent.price, compact && 'text-3xl')}>{priceLabel}</span>
|
<span className={cn('text-3xl font-semibold', accent.price, compact && 'text-2xl')}>{priceLabel}</span>
|
||||||
{pkg.price !== 0 && (
|
{pkg.price !== 0 && (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-300">/ {cadenceLabel}</span>
|
<span className="text-xs text-gray-500 dark:text-gray-300">/ {cadenceLabel}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{variant === 'endcustomer' && (
|
<p className="text-[11px] uppercase tracking-[0.3em] text-gray-400 dark:text-gray-400">{typeLabel}</p>
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-300">
|
</div>
|
||||||
{pkg.events} × {t('packages.one_time')}
|
<Separator className="bg-gray-200/70 dark:bg-gray-800/70" />
|
||||||
</p>
|
<div className="space-y-4">
|
||||||
)}
|
{metricList}
|
||||||
|
{featureList}
|
||||||
</div>
|
</div>
|
||||||
{metricList}
|
|
||||||
{featureList}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{showCTA && onSelect && (
|
{showCTA && onSelect && (
|
||||||
<CardFooter className={cn('mt-auto', compact && 'pt-4')}>
|
<CardFooter className={cn('mt-auto flex-col gap-3', compact && 'px-5')}>
|
||||||
<div className="grid w-full grid-cols-[7fr_3fr] gap-2">
|
<div className="grid w-full gap-2 sm:grid-cols-[7fr_3fr]">
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-center rounded-full text-sm font-semibold',
|
'w-full justify-center rounded-full text-sm font-semibold',
|
||||||
'bg-pink-500 text-white hover:bg-pink-600',
|
'bg-pink-500 text-white hover:bg-pink-600',
|
||||||
compact && 'py-4 text-base',
|
compact && 'py-3 text-sm',
|
||||||
)}
|
)}
|
||||||
variant="default"
|
variant="default"
|
||||||
>
|
>
|
||||||
@@ -849,7 +899,7 @@ function PackageCard({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-center rounded-full text-sm font-semibold',
|
'w-full justify-center rounded-full text-sm font-semibold',
|
||||||
accent.buttonDefault,
|
accent.buttonDefault,
|
||||||
compact && 'py-4 text-base',
|
compact && 'py-3 text-sm',
|
||||||
)}
|
)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
@@ -862,6 +912,130 @@ function PackageCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FeaturedBundleCardProps {
|
||||||
|
pkg: Package;
|
||||||
|
onSelect?: (pkg: Package) => void;
|
||||||
|
onCtaClick?: (pkg: Package, variant: 'reseller') => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeaturedBundleCard({ pkg, onSelect, onCtaClick, className }: FeaturedBundleCardProps) {
|
||||||
|
const { t } = useTranslation('marketing');
|
||||||
|
const { t: tCommon } = useTranslation('common');
|
||||||
|
const { localizedPath } = useLocalizedRoutes();
|
||||||
|
const accent = getAccentTheme('reseller');
|
||||||
|
|
||||||
|
const purchaseUrl = localizedPath(`/bestellen/${pkg.id}`);
|
||||||
|
const numericPrice = Number(pkg.price);
|
||||||
|
const priceLabel =
|
||||||
|
numericPrice === 0
|
||||||
|
? t('packages.free')
|
||||||
|
: `${numericPrice.toLocaleString()} ${t('packages.currency.euro')}`;
|
||||||
|
const cadenceLabel = t('packages.billing_per_kontingent');
|
||||||
|
const metrics = resolvePackageMetrics(pkg, 'reseller', t, tCommon);
|
||||||
|
const highlightFeatures = buildDisplayFeatures(pkg, 'reseller').slice(0, 4);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden rounded-3xl border border-slate-900/60 bg-slate-950/80 text-white shadow-xl',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardHeader className="gap-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] font-semibold uppercase tracking-[0.25em] text-slate-300">
|
||||||
|
<span>{t('packages.subscription')}</span>
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em]',
|
||||||
|
accent.badge,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('packages.badge_best_value')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<CardTitle className="text-2xl font-semibold text-white">{pkg.name}</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-slate-200">{pkg.description}</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.3em] text-slate-300">{t('packages.price')}</p>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className={cn('text-3xl font-semibold', accent.price)}>{priceLabel}</span>
|
||||||
|
<span className="text-xs text-slate-300">/ {cadenceLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pkg.events && (
|
||||||
|
<Badge variant="outline" className="rounded-full border-white/20 bg-white/10 text-white">
|
||||||
|
{t('packages.events_badge', { count: pkg.events, defaultValue: `${pkg.events} Events` })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-slate-300">
|
||||||
|
{t('packages.limits_label', 'Limits')}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-xs text-slate-100">
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<li key={metric.key} className="flex items-start gap-2">
|
||||||
|
<Check className="mt-0.5 h-3.5 w-3.5 text-white" />
|
||||||
|
<div className="flex flex-wrap items-baseline gap-1">
|
||||||
|
<span className="text-sm font-semibold text-white">{metric.value}</span>
|
||||||
|
<span className="text-[11px] uppercase tracking-wide text-slate-300">{metric.label}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-slate-300">
|
||||||
|
{t('packages.features_label', 'Features')}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-xs text-slate-100">
|
||||||
|
{highlightFeatures.map((feature) => (
|
||||||
|
<li key={feature} className="flex items-start gap-2">
|
||||||
|
<Check className="mt-0.5 h-3.5 w-3.5 text-white" />
|
||||||
|
<span>{t(`packages.feature_${feature}`)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
{onSelect && (
|
||||||
|
<CardFooter className="mt-auto flex flex-col gap-3">
|
||||||
|
<Button asChild className="w-full justify-center rounded-full bg-pink-500 text-sm font-semibold text-white hover:bg-pink-600">
|
||||||
|
<Link
|
||||||
|
href={purchaseUrl}
|
||||||
|
onClick={() => {
|
||||||
|
onCtaClick?.(pkg, 'reseller');
|
||||||
|
localStorage.setItem('preferred_package', JSON.stringify(pkg));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('packages.to_order')}
|
||||||
|
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => onSelect(pkg)}
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-center rounded-full text-sm font-semibold text-white',
|
||||||
|
'border-white/20 bg-white/5 hover:bg-white/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('packages.view_details')}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface PackageDetailGridProps {
|
interface PackageDetailGridProps {
|
||||||
packageData: Package;
|
packageData: Package;
|
||||||
variant: 'endcustomer' | 'reseller';
|
variant: 'endcustomer' | 'reseller';
|
||||||
@@ -913,7 +1087,7 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{isHighlight && (
|
{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">
|
<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')}
|
{variant === 'reseller' ? t('packages.badge_best_value') : t('packages.badge_recommended')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1081,11 +1255,17 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
|||||||
<div className="container mx-auto space-y-12">
|
<div className="container mx-auto space-y-12">
|
||||||
<Tabs defaultValue="endcustomer" className="space-y-8">
|
<Tabs defaultValue="endcustomer" className="space-y-8">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<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">
|
<TabsList className="grid w-full max-w-md grid-cols-2 rounded-full bg-white/90 p-1 shadow-lg shadow-black/10 backdrop-blur dark:bg-gray-900/60">
|
||||||
<TabsTrigger className="rounded-full font-semibold" value="endcustomer">
|
<TabsTrigger
|
||||||
|
className="rounded-full text-xs font-semibold uppercase tracking-[0.2em] text-gray-600 data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow-none dark:text-gray-200 dark:data-[state=active]:bg-white dark:data-[state=active]:text-gray-900"
|
||||||
|
value="endcustomer"
|
||||||
|
>
|
||||||
{t('packages.tab_endcustomer')}
|
{t('packages.tab_endcustomer')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger className="rounded-full font-semibold" value="reseller">
|
<TabsTrigger
|
||||||
|
className="rounded-full text-xs font-semibold uppercase tracking-[0.2em] text-gray-600 data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow-none dark:text-gray-200 dark:data-[state=active]:bg-white dark:data-[state=active]:text-gray-900"
|
||||||
|
value="reseller"
|
||||||
|
>
|
||||||
{t('packages.tab_reseller')}
|
{t('packages.tab_reseller')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -1147,6 +1327,26 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="reseller" className="space-y-8">
|
<TabsContent value="reseller" className="space-y-8">
|
||||||
|
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||||
|
<Card className="rounded-3xl border border-slate-900/40 bg-slate-950/70 text-white shadow-lg">
|
||||||
|
<CardHeader className="gap-3">
|
||||||
|
<CardTitle className="text-2xl font-display text-white">{t('packages.bundles_title')}</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-slate-200">
|
||||||
|
{t('packages.bundles_description')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl bg-white/10 px-4 py-3">
|
||||||
|
<LayoutGrid className="h-4 w-4 text-pink-200" />
|
||||||
|
<p className="text-sm font-semibold text-white">{t('packages.feature_reseller_dashboard')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl bg-white/10 px-4 py-3">
|
||||||
|
<Headphones className="h-4 w-4 text-pink-200" />
|
||||||
|
<p className="text-sm font-semibold text-white">{t('packages.feature_priority_support')}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<div className="relative">
|
<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 left-0 w-6 bg-gradient-to-r from-white to-transparent dark:from-gray-950" />
|
||||||
@@ -1175,7 +1375,7 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
|
<div className="hidden gap-6 md:grid md:grid-cols-2 xl:grid-cols-3">
|
||||||
{orderedResellerPackages.map((pkg) => (
|
{resellerBundles.map((pkg) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={pkg.id}
|
key={pkg.id}
|
||||||
variants={revealUp}
|
variants={revealUp}
|
||||||
@@ -1186,7 +1386,7 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
|||||||
<PackageCard
|
<PackageCard
|
||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
variant="reseller"
|
variant="reseller"
|
||||||
highlight={pkg.id === highlightResellerId}
|
highlight={false}
|
||||||
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
||||||
onCtaClick={handleCtaClick}
|
onCtaClick={handleCtaClick}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
@@ -1194,6 +1394,23 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
{highlightResellerPackage && (
|
||||||
|
<motion.div
|
||||||
|
key={highlightResellerPackage.id}
|
||||||
|
variants={revealUp}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={viewportOnce}
|
||||||
|
className="md:col-span-2 xl:col-span-2"
|
||||||
|
>
|
||||||
|
<FeaturedBundleCard
|
||||||
|
pkg={highlightResellerPackage}
|
||||||
|
onSelect={(selected) => handleCardClick(selected, 'reseller')}
|
||||||
|
onCtaClick={handleCtaClick}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
<motion.div variants={revealUp} initial="hidden" whileInView="visible" viewport={viewportOnce}>
|
||||||
<PackageComparison packages={orderedResellerPackages} variant="reseller" serviceTierNames={serviceTierNames} />
|
<PackageComparison packages={orderedResellerPackages} variant="reseller" serviceTierNames={serviceTierNames} />
|
||||||
@@ -1203,6 +1420,82 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="px-4 pb-16 md:pb-20">
|
||||||
|
<div className="container mx-auto space-y-8">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<h2 className="text-3xl font-bold font-display">{t('packages.calculator_title')}</h2>
|
||||||
|
<p className="text-muted-foreground">{t('packages.calculator_description')}</p>
|
||||||
|
</div>
|
||||||
|
<Card className="mx-auto max-w-xl rounded-3xl border border-gray-100 bg-white/90 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||||
|
<CardHeader className="gap-2 text-center">
|
||||||
|
<CardTitle className="text-xl font-display text-gray-900 dark:text-gray-50">
|
||||||
|
{t('packages.calculator_question')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-gray-500 dark:text-gray-300">
|
||||||
|
{t('packages.calculator_hint')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-[0.25em] text-gray-400">
|
||||||
|
<span>{t('packages.calculator_min_label')}</span>
|
||||||
|
<span>{t('packages.calculator_max_label', { count: guestSliderMax.toLocaleString() })}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={guestSliderMin}
|
||||||
|
max={guestSliderMax}
|
||||||
|
step={5}
|
||||||
|
value={guestCount}
|
||||||
|
onChange={(event) => setGuestCount(Number(event.target.value))}
|
||||||
|
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-pink-500 dark:bg-gray-800"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span>{t('packages.max_guests_label')}</span>
|
||||||
|
<span className="text-base font-semibold text-gray-900 dark:text-gray-50">
|
||||||
|
{guestCount.toLocaleString()} {t('packages.max_guests')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{recommendedPackage && (() => {
|
||||||
|
const limit = recommendedPackage.limits?.max_guests ?? recommendedPackage.max_guests ?? null;
|
||||||
|
const hasLimit = typeof limit === 'number' && Number.isFinite(limit);
|
||||||
|
const limitLabel = hasLimit ? limit.toLocaleString() : tCommon('unlimited');
|
||||||
|
const priceLabel =
|
||||||
|
Number(recommendedPackage.price) === 0
|
||||||
|
? t('packages.free')
|
||||||
|
: `${Number(recommendedPackage.price).toLocaleString()} ${t('packages.currency.euro')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-gray-100 bg-gray-50 px-4 py-4 text-center dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.25em] text-gray-400">
|
||||||
|
{t('packages.calculator_recommendation')}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-col items-center gap-2">
|
||||||
|
<p className="text-xl font-semibold text-gray-900 dark:text-gray-50">
|
||||||
|
{recommendedPackage.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||||
|
{priceLabel} · {hasLimit
|
||||||
|
? t('packages.calculator_recommendation_hint', { count: limitLabel })
|
||||||
|
: t('packages.calculator_recommendation_hint_unlimited')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full border-gray-200 text-sm font-semibold text-gray-700 hover:bg-white dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
|
||||||
|
onClick={() => handleCardClick(recommendedPackage, 'endcustomer')}
|
||||||
|
>
|
||||||
|
{t('packages.view_details')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="py-20 px-4 bg-muted/30 dark:bg-gray-900">
|
<section className="py-20 px-4 bg-muted/30 dark:bg-gray-900">
|
||||||
<div className="container mx-auto space-y-10">
|
<div className="container mx-auto space-y-10">
|
||||||
<div className="text-center space-y-3">
|
<div className="text-center space-y-3">
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
"tab_reseller": "Partner / Agentur",
|
"tab_reseller": "Partner / Agentur",
|
||||||
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)",
|
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)",
|
||||||
"section_reseller": "Packages für Partner / Agenturen (Event-Kontingent)",
|
"section_reseller": "Packages für Partner / Agenturen (Event-Kontingent)",
|
||||||
|
"bundles_title": "Partner & Agentur Bundles",
|
||||||
|
"bundles_description": "Event-Kontingente für Profis. Inklusive Partner-Dashboard und priorisiertem Support.",
|
||||||
"free": "Kostenlos",
|
"free": "Kostenlos",
|
||||||
"one_time": "Einmalkauf",
|
"one_time": "Einmalkauf",
|
||||||
"subscription": "Einmalkauf",
|
"subscription": "Einmalkauf",
|
||||||
@@ -62,7 +64,9 @@
|
|||||||
"priority_support": "Priorisierter Support",
|
"priority_support": "Priorisierter Support",
|
||||||
"badge_best_value": "Bestes Preis‑Leistungs‑Verhältnis",
|
"badge_best_value": "Bestes Preis‑Leistungs‑Verhältnis",
|
||||||
"badge_most_popular": "Beliebt",
|
"badge_most_popular": "Beliebt",
|
||||||
|
"badge_recommended": "Empfehlung",
|
||||||
"badge_starter": "Start",
|
"badge_starter": "Start",
|
||||||
|
"events_badge": "{{count}} Events",
|
||||||
"view_details": "Details",
|
"view_details": "Details",
|
||||||
"included_package_label": "Inklusive Event-Level",
|
"included_package_label": "Inklusive Event-Level",
|
||||||
"recommended_usage_label": "Empfehlung",
|
"recommended_usage_label": "Empfehlung",
|
||||||
@@ -111,6 +115,15 @@
|
|||||||
"for_resellers": "Für Partner / Agenturen",
|
"for_resellers": "Für Partner / Agenturen",
|
||||||
"details_show": "Details anzeigen",
|
"details_show": "Details anzeigen",
|
||||||
"comparison_title": "Packages vergleichen",
|
"comparison_title": "Packages vergleichen",
|
||||||
|
"calculator_title": "Noch unschlüssig?",
|
||||||
|
"calculator_description": "Nutzen Sie unseren Kalkulator, um das passende Paket basierend auf Ihrer Gästeanzahl zu finden.",
|
||||||
|
"calculator_question": "Wie viele Gäste erwarten Sie?",
|
||||||
|
"calculator_hint": "Bewegen Sie den Regler und erhalten Sie eine Paket-Empfehlung.",
|
||||||
|
"calculator_min_label": "1 Gast",
|
||||||
|
"calculator_max_label": "{{count}}+ Gäste",
|
||||||
|
"calculator_recommendation": "Wir empfehlen",
|
||||||
|
"calculator_recommendation_hint": "Ideal für bis zu {{count}} Gäste.",
|
||||||
|
"calculator_recommendation_hint_unlimited": "Ideal für Events mit beliebig vielen Gästen.",
|
||||||
"price": "Preis",
|
"price": "Preis",
|
||||||
"max_photos_label": "Max. Fotos",
|
"max_photos_label": "Max. Fotos",
|
||||||
"max_guests_label": "Max. Gäste",
|
"max_guests_label": "Max. Gäste",
|
||||||
|
|||||||
@@ -50,11 +50,14 @@
|
|||||||
"tab_reseller": "Partner / Agency",
|
"tab_reseller": "Partner / Agency",
|
||||||
"section_endcustomer": "Packages for End Customers (One-time purchase per Event)",
|
"section_endcustomer": "Packages for End Customers (One-time purchase per Event)",
|
||||||
"section_reseller": "Packages for Partner / Agencies (Event-Bundle)",
|
"section_reseller": "Packages for Partner / Agencies (Event-Bundle)",
|
||||||
|
"bundles_title": "Partner & Agency Bundles",
|
||||||
|
"bundles_description": "Event bundles for agencies. Includes partner dashboard and priority support.",
|
||||||
"free": "Free",
|
"free": "Free",
|
||||||
"one_time": "One-time purchase",
|
"one_time": "One-time purchase",
|
||||||
"subscription": "One-time purchase",
|
"subscription": "One-time purchase",
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
"billing_per_event": "per event",
|
"billing_per_event": "per event",
|
||||||
|
"billing_per_kontingent": "per bundle",
|
||||||
"billing_per_bundle": "per bundle",
|
"billing_per_bundle": "per bundle",
|
||||||
"available": "Available",
|
"available": "Available",
|
||||||
"not_available": "Not available",
|
"not_available": "Not available",
|
||||||
@@ -62,7 +65,9 @@
|
|||||||
"priority_support": "Priority support",
|
"priority_support": "Priority support",
|
||||||
"badge_best_value": "Best value",
|
"badge_best_value": "Best value",
|
||||||
"badge_most_popular": "Most popular",
|
"badge_most_popular": "Most popular",
|
||||||
|
"badge_recommended": "Recommended",
|
||||||
"badge_starter": "Starter",
|
"badge_starter": "Starter",
|
||||||
|
"events_badge": "{{count}} events",
|
||||||
"view_details": "Details",
|
"view_details": "Details",
|
||||||
"included_package_label": "Included event tier",
|
"included_package_label": "Included event tier",
|
||||||
"recommended_usage_label": "Recommendation",
|
"recommended_usage_label": "Recommendation",
|
||||||
@@ -111,6 +116,15 @@
|
|||||||
"for_resellers": "For Partner / Agencies",
|
"for_resellers": "For Partner / Agencies",
|
||||||
"details_show": "Show Details",
|
"details_show": "Show Details",
|
||||||
"comparison_title": "Compare Packages",
|
"comparison_title": "Compare Packages",
|
||||||
|
"calculator_title": "Still unsure?",
|
||||||
|
"calculator_description": "Use our calculator to find the right package based on your guest count.",
|
||||||
|
"calculator_question": "How many guests are you expecting?",
|
||||||
|
"calculator_hint": "Move the slider to get a package recommendation.",
|
||||||
|
"calculator_min_label": "1 guest",
|
||||||
|
"calculator_max_label": "{{count}}+ guests",
|
||||||
|
"calculator_recommendation": "We recommend",
|
||||||
|
"calculator_recommendation_hint": "Ideal for up to {{count}} guests.",
|
||||||
|
"calculator_recommendation_hint_unlimited": "Ideal for events with unlimited guests.",
|
||||||
"price": "Price",
|
"price": "Price",
|
||||||
"max_photos_label": "Max. Photos",
|
"max_photos_label": "Max. Photos",
|
||||||
"max_guests_label": "Max. Guests",
|
"max_guests_label": "Max. Guests",
|
||||||
|
|||||||
Reference in New Issue
Block a user