213 lines
8.1 KiB
TypeScript
213 lines
8.1 KiB
TypeScript
import React, { useMemo } from "react";
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { TFunction } from 'i18next';
|
|
import { Check, Package as PackageIcon } from "lucide-react";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { cn } from "@/lib/utils";
|
|
import { useCheckoutWizard } from "../WizardContext";
|
|
import type { CheckoutPackage } from "../types";
|
|
|
|
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
|
style: "currency",
|
|
currency: "EUR",
|
|
minimumFractionDigits: 2,
|
|
});
|
|
|
|
function translateFeature(feature: string, t: TFunction<'marketing'>) {
|
|
const fallback = feature.replace(/_/g, ' ');
|
|
return t(`packages.feature_${feature}`, { defaultValue: fallback });
|
|
}
|
|
|
|
const DETAIL_LABEL_MAP: Record<string, string> = {
|
|
fotos: 'photos',
|
|
photos: 'photos',
|
|
gaeste: 'guests',
|
|
gäste: 'guests',
|
|
guests: 'guests',
|
|
aufgaben: 'tasks',
|
|
challenges: 'tasks',
|
|
galerie: 'gallery',
|
|
gallery: 'gallery',
|
|
branding: 'branding',
|
|
'events_jahr': 'events_per_year',
|
|
eventsjahr: 'events_per_year',
|
|
};
|
|
|
|
function translateDetailLabel(label: string | undefined, t: TFunction<'marketing'>): string | undefined {
|
|
if (!label) {
|
|
return label;
|
|
}
|
|
|
|
const normalised = label
|
|
.normalize('NFD')
|
|
.replace(/\p{Diacritic}/gu, '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '_')
|
|
.replace(/^_|_$/g, '') || label.toLowerCase();
|
|
|
|
const key = DETAIL_LABEL_MAP[normalised] ?? normalised;
|
|
return t(`packages.detail_labels.${key}`, { defaultValue: label });
|
|
}
|
|
|
|
function PackageSummary({ pkg, t }: { pkg: CheckoutPackage; t: TFunction<'marketing'> }) {
|
|
const isFree = pkg.price === 0;
|
|
const accentGradient = pkg.type === 'reseller'
|
|
? 'border-amber-100 bg-gradient-to-br from-amber-50/80 via-white to-amber-100/70 shadow-lg shadow-amber-100/60'
|
|
: 'border-rose-100 bg-gradient-to-br from-rose-50/80 via-white to-rose-100/70 shadow-lg shadow-rose-100/60';
|
|
|
|
return (
|
|
<Card className={cn('shadow-sm transition', isFree ? 'opacity-75' : accentGradient)}>
|
|
<CardHeader className="space-y-1">
|
|
<CardTitle className={`flex items-center gap-3 text-2xl ${isFree ? 'text-muted-foreground' : ''}`}>
|
|
<PackageIcon className={`h-6 w-6 ${isFree ? 'text-muted-foreground' : 'text-primary'}`} />
|
|
{pkg.name}
|
|
</CardTitle>
|
|
<CardDescription className="text-base text-muted-foreground">
|
|
{pkg.description}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="flex items-baseline gap-2">
|
|
<span className={`text-3xl font-semibold ${isFree ? 'text-muted-foreground' : ''}`}>
|
|
{pkg.price === 0 ? t('packages.free') : currencyFormatter.format(pkg.price)}
|
|
</span>
|
|
<Badge variant={isFree ? 'outline' : 'secondary'} className="uppercase tracking-wider text-xs">
|
|
{pkg.type === 'reseller' ? t('packages.subscription') : t('packages.one_time')}
|
|
</Badge>
|
|
</div>
|
|
{pkg.gallery_duration_label && (
|
|
<div className="rounded-md border border-dashed border-muted px-3 py-2 text-sm text-muted-foreground">
|
|
{t('packages.gallery_days_label')}: {pkg.gallery_duration_label}
|
|
</div>
|
|
)}
|
|
{Array.isArray(pkg.description_breakdown) && pkg.description_breakdown.length > 0 && (
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{t('packages.breakdown_label')}
|
|
</h4>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
{pkg.description_breakdown.map((row, index) => (
|
|
<div key={index} className="rounded-lg border border-muted/40 bg-muted/20 px-3 py-2">
|
|
{row.title && (
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{translateDetailLabel(row.title, t)}
|
|
</p>
|
|
)}
|
|
<p className="text-sm text-muted-foreground">{row.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{Array.isArray(pkg.features) && pkg.features.length > 0 && (
|
|
<ul className="space-y-3">
|
|
{pkg.features.map((feature, index) => (
|
|
<li key={index} className="flex items-start gap-3 text-sm text-muted-foreground">
|
|
<Check className={`mt-0.5 h-4 w-4 ${isFree ? 'text-muted-foreground' : 'text-primary'}`} />
|
|
<span>{translateFeature(feature, t)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function PackageOption({ pkg, isActive, onSelect, t }: { pkg: CheckoutPackage; isActive: boolean; onSelect: () => void; t: TFunction<'marketing'> }) {
|
|
const isFree = pkg.price === 0;
|
|
const accentGradient = pkg.type === 'reseller'
|
|
? 'border-amber-100 bg-gradient-to-r from-amber-50/70 via-white to-amber-100/60 shadow-md shadow-amber-100/60'
|
|
: 'border-rose-100 bg-gradient-to-r from-rose-50/70 via-white to-rose-100/60 shadow-md shadow-rose-100/60';
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onSelect}
|
|
className={cn(
|
|
'w-full rounded-md border bg-background p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40',
|
|
isActive
|
|
? accentGradient
|
|
: isFree
|
|
? 'border-border hover:border-primary/40 opacity-75 hover:opacity-100'
|
|
: 'border-border hover:border-primary/40',
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between text-sm font-medium">
|
|
<span className={isFree ? "text-muted-foreground" : ""}>{pkg.name}</span>
|
|
<span className={isFree ? "text-muted-foreground font-normal" : "text-muted-foreground"}>
|
|
{pkg.price === 0 ? t('packages.free') : currencyFormatter.format(pkg.price)}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{pkg.description}</p>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export const PackageStep: React.FC = () => {
|
|
const { t } = useTranslation('marketing');
|
|
const { selectedPackage, packageOptions, setSelectedPackage, resetPaymentState } = useCheckoutWizard();
|
|
|
|
const comparablePackages = useMemo(() => {
|
|
if (!selectedPackage) {
|
|
return [];
|
|
}
|
|
// Filter by type and sort: free packages first, then by price ascending
|
|
return packageOptions
|
|
.filter((pkg: CheckoutPackage) => pkg.type === selectedPackage.type)
|
|
.sort((a: CheckoutPackage, b: CheckoutPackage) => {
|
|
// Free packages first
|
|
if (a.price === 0 && b.price > 0) return -1;
|
|
if (a.price > 0 && b.price === 0) return 1;
|
|
// Then sort by price ascending
|
|
return a.price - b.price;
|
|
});
|
|
}, [packageOptions, selectedPackage]);
|
|
|
|
const handlePackageChange = (pkg: CheckoutPackage) => {
|
|
if (pkg.id === selectedPackage.id) {
|
|
return;
|
|
}
|
|
setSelectedPackage(pkg);
|
|
resetPaymentState();
|
|
};
|
|
|
|
if (!selectedPackage) {
|
|
return (
|
|
<div className="text-center py-8">
|
|
<p className="text-muted-foreground">{t('checkout.package_step.no_package_selected')}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-8 lg:grid-cols-[2fr_1fr]">
|
|
<div className="space-y-6">
|
|
<PackageSummary pkg={selectedPackage} t={t} />
|
|
</div>
|
|
<aside className="space-y-4">
|
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{t('checkout.package_step.alternatives_title')}
|
|
</h3>
|
|
<div className="space-y-3">
|
|
{comparablePackages.map((pkg) => (
|
|
<PackageOption
|
|
key={pkg.id}
|
|
pkg={pkg}
|
|
isActive={pkg.id === selectedPackage.id}
|
|
onSelect={() => handlePackageChange(pkg)}
|
|
t={t}
|
|
/>
|
|
))}
|
|
{comparablePackages.length === 0 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('checkout.package_step.no_alternatives')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
);
|
|
};
|