From da862f21947abe35fee13b0cd4ed606d33c666c0 Mon Sep 17 00:00:00 2001 From: Zaid Marzguioui Date: Thu, 2 Apr 2026 15:32:55 +0200 Subject: [PATCH] feat(pricing): add competitor price comparison calculator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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€) --- .../_components/pricing-calculator.tsx | 570 ++++++++++++++++++ .../app/[locale]/(marketing)/pricing/page.tsx | 12 +- 2 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx diff --git a/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx b/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx new file mode 100644 index 000000000..b3ee4eaaf --- /dev/null +++ b/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx @@ -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 ; + } + + if (value === false) { + return ; + } + + return ( + + {value} + + ); +} + +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 ( +
+
+ + {label}{' '} + {note && ( + ({note}) + )} + + + {available ? ( + {fmt(value * mult)} € + ) : ( + + nicht verfügbar ab {fmt(maxMembers ?? 0)} + + )} + +
+
+ {available ? ( +
0 ? 4 : 0, + opacity: 0.7, + }} + /> + ) : ( +
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+ {/* ── Header ── */} +
+ + Preisvergleich + +

+ MYeasyCMS vs. Markt — was sparen Sie wirklich? +

+

+ Echte Preise von SEWOBE, easyVerein, ClubDesk und WISO MeinVerein. + Alle Preise netto. +

+
+ + {/* ── Body ── */} + + + {/* Slider */} +
+
+ + Vereinsgröße + +
+ + {fmt(members)} + + + Mitglieder + +
+
+ setMembers(+e.target.value)} + className="accent-primary w-full" + /> +
+ 50 + 500 + 1.000 + 2.500 + 5.000 + 10.000 +
+
+ + {/* Tier + Period toggle */} +
+ +
+
+ Ihr MYeasyCMS-Tarif +
+
+ {tier.name} +
+
+
+ + {tier.price} € + +
+ / Monat netto +
+
+
+ +
+ {(['month', 'year'] as const).map((p) => ( + + ))} +
+
+ + {/* Bar chart */} +
+

+ Preisvergleich bei {fmt(members)} Mitgliedern ( + {period === 'year' ? 'pro Jahr' : 'pro Monat'}) +

+ +
+ {compPrices.map((c) => ( + + ))} + + {/* MYeasyCMS bar */} +
+
+ + MYeasyCMS {tier.name} + + + {fmt(tier.price * mult)} € + +
+
+
+
+
+
+
+ + {/* Savings callout */} + {bestSaving && bestSaving.p !== null && bestSaving.p > tier.price && ( +
+ +
+ Ersparnis vs. {bestSaving.name.split(' ')[0]} +
+
+ {fmt((bestSaving.p - tier.price) * 12)} € +
+
+ pro Jahr ( + {Math.round((1 - tier.price / bestSaving.p) * 100)}% + günstiger) +
+
+ + +
+ Preis pro Mitglied +
+
+ {((tier.price / members) * 100).toFixed(1)} ct +
+
+ pro Mitglied / Monat +
+
+
+ )} + + {/* SEWOBE Verband note */} + {members >= 1000 && ( +
+ Hinweis zu SEWOBE: Ab Verbandsebene benötigt + SEWOBE den separaten VerbandsMANAGER — ein + eigenes Produkt mit eigener Preisliste (deutlich teurer als der + VereinsMANAGER). MYeasyCMS enthält das Verbandsmodul mit + Mehrebenen-Hierarchie bereits im Verband-Tarif. +
+ )} + + {/* Feature comparison table */} +
+

+ Funktionsvergleich +

+ + + + + Funktion + + MYeasyCMS + + SEWOBE + easyVerein + ClubDesk + WISO + + + + {USP_FEATURES.map((f, i) => ( + + {f.label} + {( + ['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const + ).map((col) => ( + + + + ))} + + ))} + +
+
+ + + + {/* ── CTA footer ── */} +
+
+

+ {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.'} +

+

+ 14 Tage kostenlos testen. Persönliche Einrichtung inklusive. +

+
+ + +
+
+ ); +} diff --git a/apps/web/app/[locale]/(marketing)/pricing/page.tsx b/apps/web/app/[locale]/(marketing)/pricing/page.tsx index b16b2fe97..28f377a48 100644 --- a/apps/web/app/[locale]/(marketing)/pricing/page.tsx +++ b/apps/web/app/[locale]/(marketing)/pricing/page.tsx @@ -6,6 +6,8 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; import billingConfig from '~/config/billing.config'; import pathsConfig from '~/config/paths.config'; +import { PricingCalculator } from './_components/pricing-calculator'; + export const generateMetadata = async () => { const t = await getTranslations('marketing'); @@ -23,12 +25,18 @@ async function PricingPage() { const t = await getTranslations('marketing'); return ( -
+
-
+ {/* Pricing tiers */} +
+ + {/* Price comparison calculator */} +
+ +
); }