feat: Sprint 14 — Marketing & Monetization
Deploy to TrueNAS / deploy (push) Failing after 33s

- Landing page with hero, feature grid, trust signals
- Split-layout login redesign (admin + portal)
- Pricing page with storage tiers (5GB/50GB/unlimited)
- StorageQuotaService backend (V36 migration, 402 on exceeded)
- Frontend storage integration + 402 error handling
- StorageController uses TenantContext for tenant isolation
- onTierChange() hook for subscription tier updates
This commit is contained in:
Patrick Plate
2026-06-18 20:27:54 +02:00
parent 52d23053e7
commit dad798a904
24 changed files with 2485 additions and 212 deletions
+77 -2
View File
@@ -44,7 +44,8 @@
"emailInvalid": "Bitte gib eine gültige E-Mail-Adresse ein.",
"passwordRequired": "Bitte gib dein Passwort ein.",
"passwordTooShort": "Passwort muss mindestens 8 Zeichen lang sein.",
"footerText": "Sichere Verwaltung für deinen Cannabis-Anbauverein"
"footerText": "Sichere Verwaltung für deinen Cannabis-Anbauverein",
"loginTitle": "Anmelden"
},
"dashboard": {
"title": "Dashboard",
@@ -668,6 +669,18 @@
"starter": "E-Mail",
"pro": "Priorität",
"enterprise": "Dediziert"
},
"compStorage": {
"label": "Speicher",
"starter": "5 GB",
"pro": "50 GB",
"enterprise": "Individuell"
},
"compOverage": {
"label": "Überschreitung",
"starter": "Upgrade nötig",
"pro": "0,15 €/GB/Mo",
"enterprise": "—"
}
},
"faq": {
@@ -690,7 +703,29 @@
"migration": {
"question": "Kann ich den Plan später wechseln?",
"answer": "Ja, du kannst jederzeit zwischen Starter und Pro wechseln. Ein Upgrade wird sofort wirksam, ein Downgrade zum nächsten Abrechnungszeitraum."
},
"storage": {
"question": "Was passiert, wenn mein Speicher voll ist?",
"answer": "Im Starter-Plan kannst du auf Pro upgraden. Im Pro-Plan wird zusätzlicher Speicher mit 0,15 €/GB/Monat berechnet. Enterprise-Kunden haben individuelle Speichervereinbarungen."
}
},
"storage": {
"starter": "5 GB Speicher",
"pro": "50 GB Speicher",
"proOverage": "(danach 0,15 €/GB/Monat)",
"enterprise": "Individueller Speicher",
"comparisonTitle": "Funktionen im Vergleich",
"featureMembers": "Mitglieder",
"featureStorage": "Speicher",
"featureOverage": "Überschreitung",
"featureGrow": "Grow-Kalender",
"featureApi": "API-Zugang",
"featureMultiClub": "Multi-Club",
"overageUpgrade": "Upgrade erforderlich",
"overagePro": "0,15 €/GB/Mo",
"overageEnterprise": "—",
"unlimited": "Unbegrenzt",
"custom": "Individuell"
}
},
"impressum": {
@@ -753,6 +788,46 @@
"s9Content": "Der Anbieter verarbeitet personenbezogene Daten gemäß der Datenschutzerklärung und den Bestimmungen der DSGVO. Soweit der Anbieter Daten im Auftrag des Nutzers verarbeitet, wird ein gesonderter Auftragsverarbeitungsvertrag geschlossen.",
"s10Title": "§ 10 Schlussbestimmungen",
"s10Content": "Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist, soweit gesetzlich zulässig, der Sitz des Anbieters. Sollten einzelne Bestimmungen dieser AGB unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt. Änderungen der AGB werden dem Nutzer rechtzeitig mitgeteilt."
},
"home": {
"heroTitle": "Die smarte Verwaltung für deinen Anbauverein",
"heroSubtitle": "Compliance, Anbau, Mitglieder und Abgaben — alles in einer Plattform. Rechtssicher nach KCanG.",
"ctaPrimary": "Preise ansehen",
"ctaSecondary": "Jetzt anmelden",
"featuresTitle": "Alles, was dein Verein braucht",
"featuresSubtitle": "Von der Mitgliederverwaltung bis zur Behördenmeldung — CannaManage deckt den gesamten Vereinsbetrieb ab.",
"feature1Title": "Compliance Tracking",
"feature1Desc": "Automatische Überwachung der KCanG-Vorgaben mit Fristen und Checklisten.",
"feature2Title": "Grow Management",
"feature2Desc": "Anbaukalender, Wachstumsphasen und Sensorintegration.",
"feature3Title": "Mitglieder-Portal",
"feature3Desc": "Self-Service für Mitglieder: Profil, Abgabehistorie, Dokumente.",
"feature4Title": "Abgabe-Quotas",
"feature4Desc": "Automatische Einhaltung der 25g/Tag und 50g/Monat Grenzen.",
"feature5Title": "Dokumenten-Archiv",
"feature5Desc": "GoBD-konforme Ablage mit Aufbewahrungsfristen und Versionierung.",
"feature6Title": "Finanzverwaltung",
"feature6Desc": "Mitgliedsbeiträge, SEPA-Export und Bankimport.",
"trustTitle": "Vertrauen durch Compliance",
"trustCanverg": "CanVerG-konform",
"trustDsgvo": "DSGVO & GoBD",
"trustEncryption": "TLS-verschlüsselt",
"trustGerman": "Hosting in Deutschland",
"ctaFinalTitle": "Bereit für den nächsten Schritt?",
"ctaFinalSubtitle": "Starte jetzt mit CannaManage und bring deinen Verein auf das nächste Level.",
"ctaFinalButton": "Kostenlos testen"
},
"nav": {
"features": "Features",
"pricing": "Preise",
"login": "Anmelden",
"footerTagline": "Die sichere Verwaltungssoftware für Cannabis-Anbauvereine in Deutschland.",
"footerProduct": "Produkt",
"footerLegal": "Rechtliches",
"impressum": "Impressum",
"datenschutz": "Datenschutz",
"agb": "AGB",
"allRightsReserved": "Alle Rechte vorbehalten."
}
},
"infoBoard": {
@@ -1210,4 +1285,4 @@
"size": "Größe"
}
}
}
}
+77 -2
View File
@@ -44,7 +44,8 @@
"emailInvalid": "Please enter a valid email address.",
"passwordRequired": "Please enter your password.",
"passwordTooShort": "Password must be at least 8 characters.",
"footerText": "Secure management for your cannabis cultivation club"
"footerText": "Secure management for your cannabis cultivation club",
"loginTitle": "Sign In"
},
"dashboard": {
"title": "Dashboard",
@@ -668,6 +669,18 @@
"starter": "Email",
"pro": "Priority",
"enterprise": "Dedicated"
},
"compStorage": {
"label": "Storage",
"starter": "5 GB",
"pro": "50 GB",
"enterprise": "Custom"
},
"compOverage": {
"label": "Overage",
"starter": "Upgrade required",
"pro": "€0.15/GB/mo",
"enterprise": "—"
}
},
"faq": {
@@ -690,7 +703,29 @@
"migration": {
"question": "Can I switch plans later?",
"answer": "Yes, you can switch between Starter and Pro at any time. Upgrades take effect immediately, downgrades at the next billing period."
},
"storage": {
"question": "What happens when my storage is full?",
"answer": "On the Starter plan, you can upgrade to Pro. On the Pro plan, additional storage is billed at €0.15/GB/month. Enterprise customers have custom storage agreements."
}
},
"storage": {
"starter": "5 GB Storage",
"pro": "50 GB Storage",
"proOverage": "(then €0.15/GB/month)",
"enterprise": "Custom Storage",
"comparisonTitle": "Feature Comparison",
"featureMembers": "Members",
"featureStorage": "Storage",
"featureOverage": "Overage",
"featureGrow": "Grow Calendar",
"featureApi": "API Access",
"featureMultiClub": "Multi-Club",
"overageUpgrade": "Upgrade required",
"overagePro": "€0.15/GB/mo",
"overageEnterprise": "—",
"unlimited": "Unlimited",
"custom": "Custom"
}
},
"impressum": {
@@ -753,6 +788,46 @@
"s9Content": "The provider processes personal data in accordance with the privacy policy and GDPR provisions. Where the provider processes data on behalf of the user, a separate data processing agreement is concluded.",
"s10Title": "§ 10 Final Provisions",
"s10Content": "The law of the Federal Republic of Germany applies. The place of jurisdiction is, to the extent legally permissible, the registered office of the provider. Should individual provisions of these terms be invalid, the validity of the remaining provisions remains unaffected. Changes to these terms will be communicated to the user in good time."
},
"home": {
"heroTitle": "The Smart Management for Your Cannabis Club",
"heroSubtitle": "Compliance, growing, members and distributions — all in one platform. Legally compliant under KCanG.",
"ctaPrimary": "View Pricing",
"ctaSecondary": "Sign In",
"featuresTitle": "Everything Your Club Needs",
"featuresSubtitle": "From member management to regulatory reporting — CannaManage covers all aspects of your club operations.",
"feature1Title": "Compliance Tracking",
"feature1Desc": "Automatic monitoring of KCanG requirements with deadlines and checklists.",
"feature2Title": "Grow Management",
"feature2Desc": "Growing calendar, growth phases and sensor integration.",
"feature3Title": "Member Portal",
"feature3Desc": "Self-service for members: profile, distribution history, documents.",
"feature4Title": "Distribution Quotas",
"feature4Desc": "Automatic enforcement of 25g/day and 50g/month limits.",
"feature5Title": "Document Archive",
"feature5Desc": "GoBD-compliant storage with retention periods and versioning.",
"feature6Title": "Financial Management",
"feature6Desc": "Membership fees, SEPA export and bank import.",
"trustTitle": "Trust Through Compliance",
"trustCanverg": "CanVerG compliant",
"trustDsgvo": "GDPR & GoBD",
"trustEncryption": "TLS encrypted",
"trustGerman": "Hosted in Germany",
"ctaFinalTitle": "Ready for the Next Step?",
"ctaFinalSubtitle": "Start with CannaManage now and take your club to the next level.",
"ctaFinalButton": "Try for Free"
},
"nav": {
"features": "Features",
"pricing": "Pricing",
"login": "Sign In",
"footerTagline": "The secure management software for cannabis cultivation clubs in Germany.",
"footerProduct": "Product",
"footerLegal": "Legal",
"impressum": "Imprint",
"datenschutz": "Privacy Policy",
"agb": "Terms",
"allRightsReserved": "All rights reserved."
}
},
"infoBoard": {
@@ -1219,4 +1294,4 @@
"size": "Size"
}
}
}
}
+76 -5
View File
@@ -1,5 +1,6 @@
import { NextIntlClientProvider } from "next-intl"
import { getMessages } from "next-intl/server"
import { Cannabis, ClipboardCheck, Scale, Users } from "lucide-react"
import type { ReactNode } from "react"
@@ -11,10 +12,80 @@ export default async function AuthLayout({
const messages = await getMessages()
return (
<div className="fixed inset-0 z-50 flex min-h-screen items-center justify-center bg-background text-foreground p-4">
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</div>
<NextIntlClientProvider messages={messages}>
<div className="fixed inset-0 z-50 flex min-h-screen bg-background text-foreground">
{/* Left panel — branding (hidden on mobile) */}
<div className="hidden md:flex md:w-1/2 lg:w-[55%] flex-col items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-background p-12 relative overflow-hidden">
{/* Decorative background blur */}
<div className="absolute inset-0 -z-10">
<div className="absolute top-1/4 left-1/4 h-64 w-64 rounded-full bg-primary/10 blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 h-48 w-48 rounded-full bg-primary/5 blur-2xl" />
</div>
<div className="flex flex-col items-center gap-8 max-w-sm text-center">
{/* Logo */}
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 border border-primary/20">
<Cannabis className="h-9 w-9 text-primary" />
</div>
{/* App name & tagline */}
<div className="space-y-2">
<h1 className="text-2xl font-bold">CannaManage</h1>
<p className="text-muted-foreground">
Dein Verein, digital verwaltet
</p>
</div>
{/* Feature highlights */}
<div className="space-y-4 text-left w-full">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<ClipboardCheck className="h-4 w-4 text-primary" />
</div>
<div>
<p className="text-sm font-medium">
KCanG-Compliance
</p>
<p className="text-xs text-muted-foreground">
Automatische Vorgaben-Überwachung
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Users className="h-4 w-4 text-primary" />
</div>
<div>
<p className="text-sm font-medium">
Mitgliederverwaltung
</p>
<p className="text-xs text-muted-foreground">
Portal, Profile und Dokumente
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Scale className="h-4 w-4 text-primary" />
</div>
<div>
<p className="text-sm font-medium">
Abgabe-Tracking
</p>
<p className="text-xs text-muted-foreground">
25g/Tag und 50g/Monat automatisch
</p>
</div>
</div>
</div>
</div>
</div>
{/* Right panel — form */}
<div className="w-full md:w-1/2 lg:w-[45%] flex items-center justify-center p-6 sm:p-8">
{children}
</div>
</div>
</NextIntlClientProvider>
)
}
@@ -8,7 +8,7 @@ import { signIn } from "next-auth/react"
import { useTranslations } from "next-intl"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Cannabis, Loader2 } from "lucide-react"
import { Loader2 } from "lucide-react"
const loginSchema = z.object({
email: z.string().email(),
@@ -55,13 +55,10 @@ export default function LoginPage() {
}
return (
<div className="w-full max-w-md space-y-8">
{/* Logo & Branding */}
<div className="flex flex-col items-center space-y-2">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
<Cannabis className="h-8 w-8 text-primary" />
</div>
<h1 className="text-2xl font-bold tracking-tight">CannaManage</h1>
<div className="w-full max-w-sm space-y-6">
{/* Title — visible on mobile where left panel is hidden */}
<div className="space-y-2 text-center md:text-left">
<h1 className="text-2xl font-bold tracking-tight">{t("loginTitle")}</h1>
<p className="text-sm text-muted-foreground">{t("loginSubtitle")}</p>
</div>
@@ -1,10 +1,10 @@
import Link from "next/link"
import { NextIntlClientProvider } from "next-intl"
import { getMessages } from "next-intl/server"
import { Cannabis } from "lucide-react"
import type { ReactNode } from "react"
import MarketingLayoutClient from "./marketing-layout-client"
// Force dynamic rendering — prevents NextAuth from being called at build time
// (AUTH_URL is not available during Docker image build)
export const dynamic = "force-dynamic"
@@ -18,108 +18,7 @@ export default async function MarketingLayout({
return (
<NextIntlClientProvider messages={messages}>
<div className="min-h-screen flex flex-col bg-background text-foreground overflow-x-hidden">
{/* Header */}
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex h-16 items-center justify-between px-4">
<Link href="/" className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Cannabis className="h-5 w-5 text-primary" />
</div>
<span className="text-lg font-bold">CannaManage</span>
</Link>
<nav className="flex items-center gap-4">
<Link
href="/pricing"
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Preise
</Link>
<Link
href="/login"
className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Anmelden
</Link>
</nav>
</div>
</header>
{/* Main content */}
<main className="flex-1">{children}</main>
{/* Footer */}
<footer className="border-t bg-muted/50">
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
<div>
<div className="flex items-center gap-2 mb-3">
<Cannabis className="h-5 w-5 text-primary" />
<span className="font-semibold">CannaManage</span>
</div>
<p className="text-sm text-muted-foreground">
Die sichere Verwaltungssoftware für Cannabis-Anbauvereine in
Deutschland.
</p>
</div>
<div>
<h4 className="font-semibold text-sm mb-3">Produkt</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<Link
href="/pricing"
className="hover:text-foreground transition-colors"
>
Preise
</Link>
</li>
<li>
<Link
href="/login"
className="hover:text-foreground transition-colors"
>
Anmelden
</Link>
</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-sm mb-3">Rechtliches</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<Link
href="/impressum"
className="hover:text-foreground transition-colors"
>
Impressum
</Link>
</li>
<li>
<Link
href="/datenschutz"
className="hover:text-foreground transition-colors"
>
Datenschutz
</Link>
</li>
<li>
<Link
href="/agb"
className="hover:text-foreground transition-colors"
>
AGB
</Link>
</li>
</ul>
</div>
</div>
<div className="mt-8 border-t pt-6 text-center text-xs text-muted-foreground">
© {new Date().getFullYear()} CannaManage Plate Software. Alle
Rechte vorbehalten.
</div>
</div>
</footer>
</div>
<MarketingLayoutClient>{children}</MarketingLayoutClient>
</NextIntlClientProvider>
)
}
@@ -0,0 +1,133 @@
"use client"
import Link from "next/link"
import { useTranslations } from "next-intl"
import { Cannabis } from "lucide-react"
import type { ReactNode } from "react"
export default function MarketingLayoutClient({
children,
}: {
children: ReactNode
}) {
const t = useTranslations("marketing.nav")
return (
<div className="min-h-screen flex flex-col bg-background text-foreground overflow-x-hidden">
{/* Header */}
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex h-16 items-center justify-between px-4">
<Link href="/" className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Cannabis className="h-5 w-5 text-primary" />
</div>
<span className="text-lg font-bold">CannaManage</span>
</Link>
<nav className="flex items-center gap-4">
<Link
href="/#features"
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{t("features")}
</Link>
<Link
href="/pricing"
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{t("pricing")}
</Link>
<Link
href="/login"
className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
{t("login")}
</Link>
</nav>
</div>
</header>
{/* Main content */}
<main className="flex-1">{children}</main>
{/* Footer */}
<footer className="border-t bg-muted/50">
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
<div>
<div className="flex items-center gap-2 mb-3">
<Cannabis className="h-5 w-5 text-primary" />
<span className="font-semibold">CannaManage</span>
</div>
<p className="text-sm text-muted-foreground">
{t("footerTagline")}
</p>
</div>
<div>
<h4 className="font-semibold text-sm mb-3">{t("footerProduct")}</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<Link
href="/#features"
className="hover:text-foreground transition-colors"
>
{t("features")}
</Link>
</li>
<li>
<Link
href="/pricing"
className="hover:text-foreground transition-colors"
>
{t("pricing")}
</Link>
</li>
<li>
<Link
href="/login"
className="hover:text-foreground transition-colors"
>
{t("login")}
</Link>
</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-sm mb-3">{t("footerLegal")}</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<Link
href="/impressum"
className="hover:text-foreground transition-colors"
>
{t("impressum")}
</Link>
</li>
<li>
<Link
href="/datenschutz"
className="hover:text-foreground transition-colors"
>
{t("datenschutz")}
</Link>
</li>
<li>
<Link
href="/agb"
className="hover:text-foreground transition-colors"
>
{t("agb")}
</Link>
</li>
</ul>
</div>
</div>
<div className="mt-8 border-t pt-6 text-center text-xs text-muted-foreground">
© {new Date().getFullYear()} CannaManage Plate Software.{" "}
{t("allRightsReserved")}
</div>
</div>
</footer>
</div>
)
}
@@ -0,0 +1,163 @@
"use client"
import Link from "next/link"
import { useTranslations } from "next-intl"
import {
ArrowRight,
Cannabis,
ClipboardCheck,
FileArchive,
Lock,
Scale,
Server,
ShieldCheck,
Sprout,
Users,
Wallet,
} from "lucide-react"
const features = [
{ id: "feature1", icon: ClipboardCheck },
{ id: "feature2", icon: Sprout },
{ id: "feature3", icon: Users },
{ id: "feature4", icon: Scale },
{ id: "feature5", icon: FileArchive },
{ id: "feature6", icon: Wallet },
]
const trustSignals = [
{ id: "trustCanverg", icon: ShieldCheck },
{ id: "trustDsgvo", icon: ClipboardCheck },
{ id: "trustEncryption", icon: Lock },
{ id: "trustGerman", icon: Server },
]
export default function HomePage() {
const t = useTranslations("marketing.home")
return (
<div className="flex flex-col">
{/* Hero Section */}
<section className="relative overflow-hidden py-20 sm:py-28 lg:py-32">
<div className="container mx-auto px-4 text-center">
<div className="mx-auto max-w-3xl">
<div className="mb-6 inline-flex items-center gap-2 rounded-full border bg-muted/50 px-4 py-1.5 text-sm">
<Cannabis className="h-4 w-4 text-primary" />
<span className="text-muted-foreground">KCanG-konform</span>
</div>
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
{t("heroTitle")}
</h1>
<p className="mt-6 text-lg text-muted-foreground sm:text-xl max-w-2xl mx-auto">
{t("heroSubtitle")}
</p>
<div className="mt-10 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
<Link
href="/pricing"
className="inline-flex h-12 items-center justify-center gap-2 rounded-lg bg-primary px-6 text-base font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
>
{t("ctaPrimary")}
<ArrowRight className="h-4 w-4" />
</Link>
<Link
href="/login"
className="inline-flex h-12 items-center justify-center gap-2 rounded-lg border bg-background px-6 text-base font-medium hover:bg-muted transition-colors"
>
{t("ctaSecondary")}
</Link>
</div>
</div>
</div>
{/* Decorative gradient */}
<div className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<div className="absolute -top-40 left-1/2 -translate-x-1/2 h-[500px] w-[800px] rounded-full bg-primary/5 blur-3xl" />
</div>
</section>
{/* Features Section */}
<section id="features" className="py-20 bg-muted/30">
<div className="container mx-auto px-4">
<div className="text-center mb-14">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
{t("featuresTitle")}
</h2>
<p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">
{t("featuresSubtitle")}
</p>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl mx-auto">
{features.map((feature) => {
const Icon = feature.icon
return (
<div
key={feature.id}
className="group rounded-xl border bg-card p-6 shadow-sm transition-all hover:shadow-md hover:border-primary/20"
>
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary group-hover:bg-primary/15 transition-colors">
<Icon className="h-6 w-6" />
</div>
<h3 className="text-lg font-semibold mb-2">
{t(`${feature.id}Title`)}
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{t(`${feature.id}Desc`)}
</p>
</div>
)
})}
</div>
</div>
</section>
{/* Trust Signals Section */}
<section className="py-20">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
{t("trustTitle")}
</h2>
</div>
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4 max-w-3xl mx-auto">
{trustSignals.map((signal) => {
const Icon = signal.icon
return (
<div
key={signal.id}
className="flex flex-col items-center gap-3 rounded-lg border bg-card p-5 text-center"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<Icon className="h-5 w-5" />
</div>
<span className="text-sm font-medium">{t(signal.id)}</span>
</div>
)
})}
</div>
</div>
</section>
{/* Final CTA Section */}
<section className="py-20 bg-muted/30">
<div className="container mx-auto px-4 text-center">
<div className="mx-auto max-w-2xl">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
{t("ctaFinalTitle")}
</h2>
<p className="mt-4 text-lg text-muted-foreground">
{t("ctaFinalSubtitle")}
</p>
<div className="mt-8">
<Link
href="/pricing"
className="inline-flex h-12 items-center justify-center gap-2 rounded-lg bg-primary px-8 text-base font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
>
{t("ctaFinalButton")}
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
</div>
</section>
</div>
)
}
@@ -10,6 +10,7 @@ const plans = [
icon: Leaf,
price: "19",
memberLimit: "30",
storage: "5",
features: [
"memberManagement",
"distributionTracking",
@@ -24,6 +25,7 @@ const plans = [
icon: Cannabis,
price: "49",
memberLimit: "100",
storage: "50",
popular: true,
features: [
"allStarter",
@@ -40,6 +42,7 @@ const plans = [
icon: Building2,
price: null,
memberLimit: "unlimited",
storage: "custom",
features: [
"allPro",
"unlimitedMembers",
@@ -58,6 +61,7 @@ const faqs = [
{ id: "cancel" },
{ id: "data" },
{ id: "migration" },
{ id: "storage" },
]
export default function PricingPage() {
@@ -129,6 +133,14 @@ export default function PricingPage() {
limit: plan.memberLimit,
})}
</p>
<div className="mt-2 inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">
{t(`storage.${plan.id}`)}
{plan.id === "pro" && (
<span className="text-muted-foreground ml-1">
{t("storage.proOverage")}
</span>
)}
</div>
</div>
<ul className="space-y-3 mb-8 flex-1">
@@ -180,6 +192,8 @@ export default function PricingPage() {
<tbody className="divide-y">
{[
"compMembers",
"compStorage",
"compOverage",
"compDistributions",
"compReports",
"compGrow",
@@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useTranslations } from "next-intl"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Cannabis, Loader2 } from "lucide-react"
import { Cannabis, ClockArrowUp, FileText, Loader2, User } from "lucide-react"
const loginSchema = z.object({
email: z.string().email(),
@@ -42,101 +42,154 @@ export default function PortalLoginPage() {
}
return (
<div className="fixed inset-0 z-50 flex min-h-screen items-center justify-center bg-background text-foreground p-4">
<div className="w-full max-w-md space-y-8">
{/* Logo & Branding */}
<div className="flex flex-col items-center space-y-2">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
<Cannabis className="h-8 w-8 text-primary" />
<div className="fixed inset-0 z-50 flex min-h-screen bg-background text-foreground">
{/* Left panel — member-focused branding (hidden on mobile) */}
<div className="hidden md:flex md:w-1/2 lg:w-[55%] flex-col items-center justify-center bg-gradient-to-br from-emerald-500/10 via-teal-500/5 to-background p-12 relative overflow-hidden">
{/* Decorative background */}
<div className="absolute inset-0 -z-10">
<div className="absolute top-1/4 left-1/4 h-64 w-64 rounded-full bg-emerald-500/10 blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 h-48 w-48 rounded-full bg-teal-500/5 blur-2xl" />
</div>
<div className="flex flex-col items-center gap-8 max-w-sm text-center">
{/* Logo */}
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10 border border-emerald-500/20">
<Cannabis className="h-9 w-9 text-emerald-600 dark:text-emerald-400" />
</div>
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground">{t("loginSubtitle")}</p>
</div>
{/* Login Card */}
<div className="rounded-xl border bg-card p-6 shadow-sm">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Error message */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
{/* Branding */}
<div className="space-y-2">
<h1 className="text-2xl font-bold">Mitgliederportal</h1>
<p className="text-muted-foreground">Willkommen zurück</p>
</div>
{/* Feature highlights */}
<div className="space-y-4 text-left w-full">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500/10">
<ClockArrowUp className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="text-sm font-medium">Abgabehistorie</p>
<p className="text-xs text-muted-foreground">Alle Abgaben auf einen Blick</p>
</div>
)}
{/* Email field */}
<div className="space-y-2">
<label
htmlFor="portal-email"
className="text-sm font-medium leading-none"
>
{t("email")}
</label>
<input
id="portal-email"
type="email"
autoComplete="email"
placeholder="max@beispiel.de"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
{...register("email")}
aria-invalid={!!errors.email}
/>
{errors.email && (
<p className="text-xs text-destructive">
{t("invalidCredentials")}
</p>
)}
</div>
{/* Password field */}
<div className="space-y-2">
<label
htmlFor="portal-password"
className="text-sm font-medium leading-none"
>
{t("password")}
</label>
<input
id="portal-password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
{...register("password")}
aria-invalid={!!errors.password}
/>
{errors.password && (
<p className="text-xs text-destructive">
{t("invalidCredentials")}
</p>
)}
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500/10">
<User className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="text-sm font-medium">Profil verwalten</p>
<p className="text-xs text-muted-foreground">Daten und Einstellungen</p>
</div>
</div>
{/* Submit button */}
<button
type="submit"
disabled={isSubmitting}
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t("loggingIn")}
</>
) : (
t("loginButton")
)}
</button>
</form>
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500/10">
<FileText className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="text-sm font-medium">Dokumente</p>
<p className="text-xs text-muted-foreground">Bescheinigungen und Nachweise</p>
</div>
</div>
</div>
</div>
</div>
{/* Footer link to admin */}
<div className="text-center">
<Link
href="/login"
className="text-xs text-muted-foreground hover:text-primary transition-colors"
>
{t("adminLogin")}
</Link>
{/* Right panel — form */}
<div className="w-full md:w-1/2 lg:w-[45%] flex items-center justify-center p-6 sm:p-8">
<div className="w-full max-w-sm space-y-6">
{/* Title */}
<div className="space-y-2 text-center md:text-left">
<h2 className="text-2xl font-bold tracking-tight">{t("title")}</h2>
<p className="text-sm text-muted-foreground">{t("loginSubtitle")}</p>
</div>
{/* Login Card */}
<div className="rounded-xl border bg-card p-6 shadow-sm">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Error message */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{/* Email field */}
<div className="space-y-2">
<label
htmlFor="portal-email"
className="text-sm font-medium leading-none"
>
{t("email")}
</label>
<input
id="portal-email"
type="email"
autoComplete="email"
placeholder="max@beispiel.de"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
{...register("email")}
aria-invalid={!!errors.email}
/>
{errors.email && (
<p className="text-xs text-destructive">
{t("invalidCredentials")}
</p>
)}
</div>
{/* Password field */}
<div className="space-y-2">
<label
htmlFor="portal-password"
className="text-sm font-medium leading-none"
>
{t("password")}
</label>
<input
id="portal-password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
{...register("password")}
aria-invalid={!!errors.password}
/>
{errors.password && (
<p className="text-xs text-destructive">
{t("invalidCredentials")}
</p>
)}
</div>
{/* Submit button */}
<button
type="submit"
disabled={isSubmitting}
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white ring-offset-background transition-colors hover:bg-emerald-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t("loggingIn")}
</>
) : (
t("loginButton")
)}
</button>
</form>
</div>
{/* Footer link to admin */}
<div className="text-center">
<Link
href="/login"
className="text-xs text-muted-foreground hover:text-primary transition-colors"
>
{t("adminLogin")}
</Link>
</div>
</div>
</div>
</div>
+13 -1
View File
@@ -73,7 +73,19 @@ export async function uploadDocument(
body: formData,
}
)
if (!res.ok) throw new Error("Upload failed")
if (!res.ok) {
if (res.status === 402) {
const problem = await res.json()
const error = new Error("Storage quota exceeded") as Error & {
status: number
problemDetail: unknown
}
error.status = 402
error.problemDetail = problem
throw error
}
throw new Error("Upload failed")
}
return res.json()
}
@@ -0,0 +1,43 @@
import { apiClient } from "@/lib/api-client"
export interface StorageUsage {
usedBytes: number
limitBytes: number
percentage: number
}
/**
* Fetch current storage usage for the authenticated user's club.
* Club ID is derived from JWT on the backend — no param needed.
*/
export function getStorageUsage(): Promise<StorageUsage> {
return apiClient<StorageUsage>("/storage/usage")
}
/**
* Format bytes into a human-readable string (e.g., "4.2 GB").
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B"
const k = 1024
const sizes = ["B", "KB", "MB", "GB", "TB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]
}
/**
* Check if an API error response indicates a storage quota exceeded (HTTP 402).
*/
export function isStorageQuotaError(error: unknown): boolean {
if (
error &&
typeof error === "object" &&
"response" in error &&
error.response &&
typeof error.response === "object" &&
"status" in error.response
) {
return (error.response as { status: number }).status === 402
}
return false
}