feat(pricing): add competitor price comparison calculator
- Interactive slider for 50–10,000 members - Real prices from SEWOBE, easyVerein, ClubDesk, WISO MeinVerein - Bar chart comparing monthly/yearly costs - Savings callout showing annual savings vs most expensive competitor - Feature comparison table (Verband, Website, Kurse, SEPA, Support, etc.) - SEWOBE Verband warning for 1000+ members - Uses shadcn/ui components (Card, Badge, Button, Table) - CTA linking to sign-up - 4 tiers: Starter (29€), Pro (59€), Verband (199€), Enterprise (349€)
This commit is contained in:
@@ -0,0 +1,570 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Check, ExternalLink, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@kit/ui/card';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@kit/ui/table';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Competitor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
getPrice: (m: number) => number | null;
|
||||||
|
color: string;
|
||||||
|
maxMembers?: number;
|
||||||
|
verbandExtra: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPETITORS: Competitor[] = [
|
||||||
|
{
|
||||||
|
id: 'sewobe',
|
||||||
|
name: 'SEWOBE VereinsManager',
|
||||||
|
note: 'VerbandsMANAGER separat',
|
||||||
|
getPrice: (m) => {
|
||||||
|
if (m <= 500) return 30;
|
||||||
|
if (m <= 1000) return 49;
|
||||||
|
if (m <= 2000) return 99;
|
||||||
|
if (m <= 3000) return 159;
|
||||||
|
if (m <= 5000) return 269;
|
||||||
|
if (m <= 7500) return 369;
|
||||||
|
return 469;
|
||||||
|
},
|
||||||
|
color: 'bg-red-500',
|
||||||
|
verbandExtra:
|
||||||
|
'VerbandsMANAGER: separates Produkt, Preis auf Anfrage (deutlich teurer)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'easyverein',
|
||||||
|
name: 'easyVerein Professional',
|
||||||
|
note: 'Dachverbandslösung extra',
|
||||||
|
getPrice: (m) => {
|
||||||
|
if (m <= 100) return 20;
|
||||||
|
if (m <= 250) return 31;
|
||||||
|
if (m <= 500) return 49;
|
||||||
|
if (m <= 1000) return 79;
|
||||||
|
if (m <= 2000) return 129;
|
||||||
|
if (m <= 5000) return 249;
|
||||||
|
return 399;
|
||||||
|
},
|
||||||
|
color: 'bg-orange-500',
|
||||||
|
verbandExtra:
|
||||||
|
'Dachverbandslösung: jede Instanz eigene kostenpflichtige Lizenz',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clubdesk',
|
||||||
|
name: 'ClubDesk',
|
||||||
|
note: 'Server in der Schweiz',
|
||||||
|
getPrice: (m) => {
|
||||||
|
if (m <= 50) return 0;
|
||||||
|
if (m <= 100) return 10;
|
||||||
|
if (m <= 250) return 15;
|
||||||
|
if (m <= 500) return 25;
|
||||||
|
if (m <= 1000) return 33;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
color: 'bg-blue-500',
|
||||||
|
maxMembers: 1000,
|
||||||
|
verbandExtra: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wiso',
|
||||||
|
name: 'WISO MeinVerein Web',
|
||||||
|
note: 'Buhl / ZDF-WISO Marke',
|
||||||
|
getPrice: (m) => {
|
||||||
|
if (m <= 100) return 10;
|
||||||
|
if (m <= 250) return 15;
|
||||||
|
if (m <= 500) return 25;
|
||||||
|
if (m <= 1000) return 35;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
color: 'bg-violet-500',
|
||||||
|
maxMembers: 1000,
|
||||||
|
verbandExtra: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{ name: 'Starter', price: 29, maxMembers: 250 },
|
||||||
|
{ name: 'Pro', price: 59, maxMembers: 1000 },
|
||||||
|
{ name: 'Verband', price: 199, maxMembers: 10000 },
|
||||||
|
{ name: 'Enterprise', price: 349, maxMembers: 99999 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function getTier(m: number) {
|
||||||
|
return TIERS.find((t) => m <= t.maxMembers) ?? TIERS[TIERS.length - 1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n: number) {
|
||||||
|
return n.toLocaleString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Feature comparison data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type FeatureValue = boolean | string;
|
||||||
|
|
||||||
|
interface FeatureRow {
|
||||||
|
label: string;
|
||||||
|
mcms: FeatureValue;
|
||||||
|
sewobe: FeatureValue;
|
||||||
|
easy: FeatureValue;
|
||||||
|
club: FeatureValue;
|
||||||
|
wiso: FeatureValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const USP_FEATURES: FeatureRow[] = [
|
||||||
|
{
|
||||||
|
label: 'Verbandsmodul (Mehrebenen-Hierarchie)',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: 'Separates Produkt',
|
||||||
|
easy: 'Extra Lösung',
|
||||||
|
club: false,
|
||||||
|
wiso: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Vereins-Website inklusive',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: false,
|
||||||
|
easy: false,
|
||||||
|
club: true,
|
||||||
|
wiso: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Kursverwaltung (Dozenten, Räume)',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: false,
|
||||||
|
easy: false,
|
||||||
|
club: false,
|
||||||
|
wiso: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'SEPA-Lastschrift',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: true,
|
||||||
|
easy: true,
|
||||||
|
club: true,
|
||||||
|
wiso: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Persönlicher Telefon-Support',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: 'Kostenpflichtig',
|
||||||
|
easy: false,
|
||||||
|
club: false,
|
||||||
|
wiso: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Unbegrenzte Benutzer (ab Pro)',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: '5 inkl., +6€/User',
|
||||||
|
easy: '3–10 inkl.',
|
||||||
|
club: false,
|
||||||
|
wiso: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Server in Deutschland',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: true,
|
||||||
|
easy: true,
|
||||||
|
club: 'Schweiz',
|
||||||
|
wiso: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Individuelle Module',
|
||||||
|
mcms: true,
|
||||||
|
sewobe: 'Aufpreis',
|
||||||
|
easy: false,
|
||||||
|
club: false,
|
||||||
|
wiso: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function FeatureCell({ value }: { value: FeatureValue }) {
|
||||||
|
if (value === true) {
|
||||||
|
return <Check className="text-primary mx-auto h-4 w-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === false) {
|
||||||
|
return <X className="text-destructive mx-auto h-3.5 w-3.5" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PriceBar({
|
||||||
|
label,
|
||||||
|
note,
|
||||||
|
value,
|
||||||
|
maxValue,
|
||||||
|
color,
|
||||||
|
available,
|
||||||
|
maxMembers,
|
||||||
|
period,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
note?: string;
|
||||||
|
value: number;
|
||||||
|
maxValue: number;
|
||||||
|
color: string;
|
||||||
|
available: boolean;
|
||||||
|
maxMembers?: number;
|
||||||
|
period: 'month' | 'year';
|
||||||
|
}) {
|
||||||
|
const mult = period === 'year' ? 12 : 1;
|
||||||
|
const pct = available ? Math.min((value / (maxValue * 1.1)) * 100, 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-muted-foreground flex items-baseline justify-between text-sm">
|
||||||
|
<span className="font-medium">
|
||||||
|
{label}{' '}
|
||||||
|
{note && (
|
||||||
|
<span className="text-muted-foreground/60 text-xs">({note})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-bold">
|
||||||
|
{available ? (
|
||||||
|
<span className="text-destructive">{fmt(value * mult)} €</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/50 text-xs">
|
||||||
|
nicht verfügbar ab {fmt(maxMembers ?? 0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted h-6 overflow-hidden rounded-md">
|
||||||
|
{available ? (
|
||||||
|
<div
|
||||||
|
className={cn('h-full rounded-md transition-all duration-500', color)}
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
minWidth: pct > 0 ? 4 : 0,
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="bg-muted-foreground/5 h-full rounded-md"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'repeating-linear-gradient(45deg, transparent, transparent 8px, rgba(0,0,0,.03) 8px, rgba(0,0,0,.03) 16px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Calculator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function PricingCalculator() {
|
||||||
|
const [members, setMembers] = useState(500);
|
||||||
|
const [period, setPeriod] = useState<'month' | 'year'>('year');
|
||||||
|
|
||||||
|
const tier = useMemo(() => getTier(members), [members]);
|
||||||
|
const mult = period === 'year' ? 12 : 1;
|
||||||
|
|
||||||
|
const compPrices = useMemo(
|
||||||
|
() => COMPETITORS.map((c) => ({ ...c, p: c.getPrice(members) })),
|
||||||
|
[members],
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxBar = useMemo(
|
||||||
|
() =>
|
||||||
|
Math.max(
|
||||||
|
tier.price,
|
||||||
|
...compPrices.filter((c) => c.p !== null).map((c) => c.p!),
|
||||||
|
),
|
||||||
|
[tier.price, compPrices],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bestSaving = useMemo(
|
||||||
|
() =>
|
||||||
|
compPrices
|
||||||
|
.filter((c) => c.p !== null && c.p > tier.price)
|
||||||
|
.sort((a, b) => b.p! - a.p!)[0] ?? null,
|
||||||
|
[compPrices, tier.price],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-4xl space-y-0">
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="bg-primary rounded-t-2xl px-8 py-7">
|
||||||
|
<Badge variant="outline" className="border-primary-foreground/20 text-primary-foreground/80 mb-1 text-[10px] uppercase tracking-widest">
|
||||||
|
Preisvergleich
|
||||||
|
</Badge>
|
||||||
|
<h2 className="font-heading text-primary-foreground text-2xl font-bold tracking-tight">
|
||||||
|
MYeasyCMS vs. Markt — was sparen Sie wirklich?
|
||||||
|
</h2>
|
||||||
|
<p className="text-primary-foreground/50 mt-1 text-sm">
|
||||||
|
Echte Preise von SEWOBE, easyVerein, ClubDesk und WISO MeinVerein.
|
||||||
|
Alle Preise netto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Body ── */}
|
||||||
|
<Card className="rounded-t-none border-t-0">
|
||||||
|
<CardContent className="space-y-6 pt-7">
|
||||||
|
{/* Slider */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-baseline justify-between">
|
||||||
|
<span className="text-muted-foreground text-sm font-semibold">
|
||||||
|
Vereinsgröße
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<span className="text-primary font-mono text-2xl font-bold">
|
||||||
|
{fmt(members)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground/60 ml-1 text-sm">
|
||||||
|
Mitglieder
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={50}
|
||||||
|
max={10000}
|
||||||
|
step={50}
|
||||||
|
value={members}
|
||||||
|
onChange={(e) => setMembers(+e.target.value)}
|
||||||
|
className="accent-primary w-full"
|
||||||
|
/>
|
||||||
|
<div className="text-muted-foreground/40 flex justify-between text-[10px]">
|
||||||
|
<span>50</span>
|
||||||
|
<span>500</span>
|
||||||
|
<span>1.000</span>
|
||||||
|
<span>2.500</span>
|
||||||
|
<span>5.000</span>
|
||||||
|
<span>10.000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier + Period toggle */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Card className="bg-primary/5 border-primary/10 flex flex-1 items-center justify-between p-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-primary text-[10px] font-bold uppercase tracking-wider">
|
||||||
|
Ihr MYeasyCMS-Tarif
|
||||||
|
</div>
|
||||||
|
<div className="font-heading text-primary text-xl font-bold">
|
||||||
|
{tier.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-primary font-mono text-3xl font-bold">
|
||||||
|
{tier.price} €
|
||||||
|
</span>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
/ Monat netto
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="border-border flex overflow-hidden rounded-lg border">
|
||||||
|
{(['month', 'year'] as const).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm font-semibold transition-colors',
|
||||||
|
period === p
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-background text-muted-foreground hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p === 'month' ? 'Monatlich' : 'Jährlich'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bar chart */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground mb-3 text-sm font-semibold">
|
||||||
|
Preisvergleich bei {fmt(members)} Mitgliedern (
|
||||||
|
{period === 'year' ? 'pro Jahr' : 'pro Monat'})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{compPrices.map((c) => (
|
||||||
|
<PriceBar
|
||||||
|
key={c.id}
|
||||||
|
label={c.name}
|
||||||
|
note={c.note}
|
||||||
|
value={c.p ?? 0}
|
||||||
|
maxValue={maxBar}
|
||||||
|
color={c.color}
|
||||||
|
available={c.p !== null}
|
||||||
|
maxMembers={c.maxMembers}
|
||||||
|
period={period}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* MYeasyCMS bar */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-baseline justify-between text-sm">
|
||||||
|
<span className="text-primary font-bold">
|
||||||
|
MYeasyCMS {tier.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-primary font-mono font-bold">
|
||||||
|
{fmt(tier.price * mult)} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted h-7 overflow-hidden rounded-md">
|
||||||
|
<div
|
||||||
|
className="from-primary/80 to-primary h-full rounded-md bg-gradient-to-r transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min((tier.price / (maxBar * 1.1)) * 100, 100)}%`,
|
||||||
|
minWidth: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Savings callout */}
|
||||||
|
{bestSaving && bestSaving.p !== null && bestSaving.p > tier.price && (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Card className="bg-primary/5 border-primary/10 p-5 text-center">
|
||||||
|
<div className="text-primary text-[10px] font-bold uppercase tracking-wider">
|
||||||
|
Ersparnis vs. {bestSaving.name.split(' ')[0]}
|
||||||
|
</div>
|
||||||
|
<div className="font-heading text-primary mt-1 text-3xl font-bold">
|
||||||
|
{fmt((bestSaving.p - tier.price) * 12)} €
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
pro Jahr (
|
||||||
|
{Math.round((1 - tier.price / bestSaving.p) * 100)}%
|
||||||
|
günstiger)
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-muted/50 p-5 text-center">
|
||||||
|
<div className="text-muted-foreground text-[10px] font-bold uppercase tracking-wider">
|
||||||
|
Preis pro Mitglied
|
||||||
|
</div>
|
||||||
|
<div className="font-heading text-primary mt-1 text-3xl font-bold">
|
||||||
|
{((tier.price / members) * 100).toFixed(1)} ct
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
pro Mitglied / Monat
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SEWOBE Verband note */}
|
||||||
|
{members >= 1000 && (
|
||||||
|
<div className="bg-destructive/5 border-destructive/10 text-destructive rounded-xl border p-4 text-sm">
|
||||||
|
<strong>Hinweis zu SEWOBE:</strong> Ab Verbandsebene benötigt
|
||||||
|
SEWOBE den separaten <strong>VerbandsMANAGER</strong> — ein
|
||||||
|
eigenes Produkt mit eigener Preisliste (deutlich teurer als der
|
||||||
|
VereinsMANAGER). MYeasyCMS enthält das Verbandsmodul mit
|
||||||
|
Mehrebenen-Hierarchie bereits im Verband-Tarif.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feature comparison table */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground mb-3 text-sm font-bold">
|
||||||
|
Funktionsvergleich
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[30%]">Funktion</TableHead>
|
||||||
|
<TableHead className="bg-primary/5 text-primary text-center font-bold">
|
||||||
|
MYeasyCMS
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-center">SEWOBE</TableHead>
|
||||||
|
<TableHead className="text-center">easyVerein</TableHead>
|
||||||
|
<TableHead className="text-center">ClubDesk</TableHead>
|
||||||
|
<TableHead className="text-center">WISO</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{USP_FEATURES.map((f, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell className="font-medium">{f.label}</TableCell>
|
||||||
|
{(
|
||||||
|
['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const
|
||||||
|
).map((col) => (
|
||||||
|
<TableCell
|
||||||
|
key={col}
|
||||||
|
className={cn(
|
||||||
|
'text-center',
|
||||||
|
col === 'mcms' && 'bg-primary/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FeatureCell value={f[col]} />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── CTA footer ── */}
|
||||||
|
<div className="bg-primary flex flex-wrap items-center justify-between gap-4 rounded-b-2xl px-8 py-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-primary-foreground text-sm font-semibold">
|
||||||
|
{members >= 1000
|
||||||
|
? `${Math.round((1 - tier.price / (compPrices[0]?.p ?? tier.price)) * 100)}% günstiger als SEWOBE — mit dem Verbandsmodul inklusive.`
|
||||||
|
: 'Alle Funktionen inklusive. Keine versteckten Kosten.'}
|
||||||
|
</p>
|
||||||
|
<p className="text-primary-foreground/40 text-xs">
|
||||||
|
14 Tage kostenlos testen. Persönliche Einrichtung inklusive.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="secondary" size="lg" asChild>
|
||||||
|
<Link href="/auth/sign-up">
|
||||||
|
Kostenlos testen
|
||||||
|
<ExternalLink className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
|||||||
import billingConfig from '~/config/billing.config';
|
import billingConfig from '~/config/billing.config';
|
||||||
import pathsConfig from '~/config/paths.config';
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
|
||||||
|
import { PricingCalculator } from './_components/pricing-calculator';
|
||||||
|
|
||||||
export const generateMetadata = async () => {
|
export const generateMetadata = async () => {
|
||||||
const t = await getTranslations('marketing');
|
const t = await getTranslations('marketing');
|
||||||
|
|
||||||
@@ -23,12 +25,18 @@ async function PricingPage() {
|
|||||||
const t = await getTranslations('marketing');
|
const t = await getTranslations('marketing');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex flex-col space-y-8'}>
|
<div className={'flex flex-col space-y-16'}>
|
||||||
<SitePageHeader title={t('pricing')} subtitle={t('pricingSubtitle')} />
|
<SitePageHeader title={t('pricing')} subtitle={t('pricingSubtitle')} />
|
||||||
|
|
||||||
<div className={'container mx-auto pb-8 xl:pb-16'}>
|
{/* Pricing tiers */}
|
||||||
|
<div className={'container mx-auto'}>
|
||||||
<PricingTable paths={paths} config={billingConfig} />
|
<PricingTable paths={paths} config={billingConfig} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Price comparison calculator */}
|
||||||
|
<div className={'container mx-auto pb-8 xl:pb-16'}>
|
||||||
|
<PricingCalculator />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user