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
+2
View File
@@ -15,3 +15,5 @@ cannamanage-frontend/.env.local
# Production secrets (never commit)
.env
~/
~/
@@ -0,0 +1,34 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.service.StorageQuotaService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* REST controller for storage quota information.
* Provides endpoint to check current storage usage for the caller's club.
* Club ID is extracted from the JWT/tenant context — not from request params.
*/
@RestController
@RequestMapping("/api/v1/storage")
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')")
public class StorageController {
private final StorageQuotaService storageQuotaService;
public StorageController(StorageQuotaService storageQuotaService) {
this.storageQuotaService = storageQuotaService;
}
@GetMapping("/usage")
public ResponseEntity<StorageQuotaService.StorageUsageDTO> getUsage() {
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(storageQuotaService.getUsage(clubId));
}
}
@@ -5,6 +5,7 @@ import de.cannamanage.service.exception.BatchNotFoundException;
import de.cannamanage.service.exception.MemberNotFoundException;
import de.cannamanage.service.exception.PreventionOfficerLimitExceededException;
import de.cannamanage.service.exception.QuotaExceededException;
import de.cannamanage.service.exception.StorageQuotaExceededException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
@@ -121,6 +122,20 @@ public class GlobalExceptionHandler {
return problem;
}
@ExceptionHandler(StorageQuotaExceededException.class)
public ProblemDetail handleStorageQuotaExceeded(StorageQuotaExceededException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.PAYMENT_REQUIRED, ex.getMessage());
problem.setTitle("Storage Quota Exceeded");
problem.setType(URI.create("urn:cannamanage:error:STORAGE_QUOTA_EXCEEDED"));
problem.setProperty("code", "STORAGE_QUOTA_EXCEEDED");
problem.setProperty("currentUsage", ex.getCurrentUsage());
problem.setProperty("limit", ex.getLimit());
problem.setProperty("requestedBytes", ex.getRequestedBytes());
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(ResponseStatusException.class)
public ProblemDetail handleResponseStatus(ResponseStatusException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
@@ -0,0 +1,9 @@
-- V36: Add storage quota tracking to clubs
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS storage_used_bytes BIGINT DEFAULT 0;
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS storage_limit_bytes BIGINT DEFAULT 5368709120;
-- Default: 5 GB (5 * 1024^3) = Starter tier
-- Backfill existing clubs with actual usage
UPDATE clubs c SET storage_used_bytes = COALESCE(
(SELECT SUM(d.file_size) FROM documents d WHERE d.club_id = c.id), 0
);
@@ -51,6 +51,12 @@ public class Club extends AbstractTenantEntity {
@Column(name = "allowed_email_pattern", length = 255)
private String allowedEmailPattern;
@Column(name = "storage_used_bytes", nullable = false)
private Long storageUsedBytes = 0L;
@Column(name = "storage_limit_bytes", nullable = false)
private Long storageLimitBytes = 5_368_709_120L; // 5 GB default
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 50)
private ClubStatus status = ClubStatus.ACTIVE;
@@ -99,4 +105,10 @@ public class Club extends AbstractTenantEntity {
public ClubStatus getStatus() { return status; }
public void setStatus(ClubStatus status) { this.status = status; }
public Long getStorageUsedBytes() { return storageUsedBytes; }
public void setStorageUsedBytes(Long storageUsedBytes) { this.storageUsedBytes = storageUsedBytes; }
public Long getStorageLimitBytes() { return storageLimitBytes; }
public void setStorageLimitBytes(Long storageLimitBytes) { this.storageLimitBytes = storageLimitBytes; }
}
+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
}
@@ -36,16 +36,22 @@ public class DocumentService {
private final DocumentRepository documentRepository;
private final AuditService auditService;
private final StorageQuotaService storageQuotaService;
public DocumentService(DocumentRepository documentRepository, AuditService auditService) {
public DocumentService(DocumentRepository documentRepository, AuditService auditService,
StorageQuotaService storageQuotaService) {
this.documentRepository = documentRepository;
this.auditService = auditService;
this.storageQuotaService = storageQuotaService;
}
@Transactional
public Document uploadDocument(UUID clubId, String title, DocumentCategory category,
DocumentAccessLevel accessLevel, String description,
MultipartFile file, UUID uploadedBy) throws IOException {
// Check storage quota before upload
storageQuotaService.checkQuota(clubId, file.getSize());
// Validate file
if (file.isEmpty()) {
throw new IllegalArgumentException("File is empty");
@@ -88,6 +94,9 @@ public class DocumentService {
Document saved = documentRepository.save(doc);
// Increment storage usage counter after successful save
storageQuotaService.incrementUsage(clubId, file.getSize());
auditService.log(AuditEventType.DOCUMENT_UPLOADED, uploadedBy, clubId,
"Document uploaded: " + title + " (" + category + ")");
@@ -133,6 +142,9 @@ public class DocumentService {
// Delete DB record
documentRepository.delete(doc);
// Decrement storage usage counter after successful delete
storageQuotaService.decrementUsage(clubId, doc.getFileSize());
auditService.log(AuditEventType.DOCUMENT_DELETED, deletedBy, clubId,
"Document deleted: " + doc.getTitle());
@@ -0,0 +1,121 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.service.exception.StorageQuotaExceededException;
import de.cannamanage.service.repository.ClubRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/**
* Manages storage quota enforcement for clubs.
* Each club has a storage_limit_bytes based on their subscription tier
* and a storage_used_bytes counter tracking actual usage.
*/
@Slf4j
@Service
public class StorageQuotaService {
// Plan tier limits
private static final long STARTER_LIMIT = 5L * 1024 * 1024 * 1024; // 5 GB
private static final long PRO_LIMIT = 50L * 1024 * 1024 * 1024; // 50 GB
private static final long ENTERPRISE_LIMIT = Long.MAX_VALUE; // Unlimited
private final ClubRepository clubRepository;
public StorageQuotaService(ClubRepository clubRepository) {
this.clubRepository = clubRepository;
}
/**
* Get current storage usage for a club.
*/
public StorageUsageDTO getUsage(UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
long used = club.getStorageUsedBytes();
long limit = club.getStorageLimitBytes();
double percentage = limit > 0 ? (double) used / limit * 100 : 0;
return new StorageUsageDTO(used, limit, percentage);
}
/**
* Check if uploading additionalBytes would exceed the club's storage limit.
* Throws StorageQuotaExceededException if it would.
*/
public void checkQuota(UUID clubId, long additionalBytes) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
long newTotal = club.getStorageUsedBytes() + additionalBytes;
if (newTotal > club.getStorageLimitBytes()) {
throw new StorageQuotaExceededException(
club.getStorageUsedBytes(), club.getStorageLimitBytes(), additionalBytes);
}
}
/**
* Increment the club's storage usage counter after a successful upload.
*/
@Transactional
public void incrementUsage(UUID clubId, long bytes) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
club.setStorageUsedBytes(club.getStorageUsedBytes() + bytes);
clubRepository.save(club);
log.debug("Club {} storage incremented by {} bytes (total: {})", clubId, bytes, club.getStorageUsedBytes());
}
/**
* Decrement the club's storage usage counter after a successful delete.
*/
@Transactional
public void decrementUsage(UUID clubId, long bytes) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
long newUsage = Math.max(0, club.getStorageUsedBytes() - bytes);
club.setStorageUsedBytes(newUsage);
clubRepository.save(club);
log.debug("Club {} storage decremented by {} bytes (total: {})", clubId, bytes, newUsage);
}
/**
* Get the storage limit in bytes for a given plan tier name.
*/
public static long getLimitForTier(String tier) {
return switch (tier.toLowerCase()) {
case "starter", "trial" -> STARTER_LIMIT;
case "pro" -> PRO_LIMIT;
case "enterprise" -> ENTERPRISE_LIMIT;
default -> STARTER_LIMIT;
};
}
/**
* Called when a club's subscription tier changes.
* Updates storage_limit_bytes to match the new tier.
*/
@Transactional
public void onTierChange(UUID clubId, String newTier) {
long newLimit = getLimitForTier(newTier);
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
club.setStorageLimitBytes(newLimit);
clubRepository.save(club);
log.info("Club {} tier changed to '{}' — storage limit updated to {} bytes", clubId, newTier, newLimit);
}
/**
* Check if a club is at or above a given usage threshold percentage.
*/
public boolean isNearLimit(UUID clubId, int thresholdPercent) {
StorageUsageDTO usage = getUsage(clubId);
return usage.percentage() >= thresholdPercent;
}
/**
* DTO for storage usage response.
*/
public record StorageUsageDTO(long usedBytes, long limitBytes, double percentage) {}
}
@@ -0,0 +1,33 @@
package de.cannamanage.service.exception;
/**
* Thrown when a document upload would exceed the club's storage quota.
* Maps to HTTP 402 Payment Required — distinct from QuotaExceededException
* which handles CanG distribution quotas (25g/day, 50g/month).
*/
public class StorageQuotaExceededException extends RuntimeException {
private final long currentUsage;
private final long limit;
private final long requestedBytes;
public StorageQuotaExceededException(long currentUsage, long limit, long requestedBytes) {
super("Storage quota exceeded: current=%d, limit=%d, requested=%d"
.formatted(currentUsage, limit, requestedBytes));
this.currentUsage = currentUsage;
this.limit = limit;
this.requestedBytes = requestedBytes;
}
public long getCurrentUsage() {
return currentUsage;
}
public long getLimit() {
return limit;
}
public long getRequestedBytes() {
return requestedBytes;
}
}
@@ -0,0 +1,123 @@
# Analysis: Sprint 14 — Marketing & Monetization
**Date:** 2026-06-18
**Author:** Patrick Plate / Lumen (Planner)
**Status:** v1
**Sprint Theme:** Marketing & Monetization
---
## 1. Problem Analysis
CannaManage is production-ready after Sprint 13's hardening. However, the public-facing marketing surfaces are minimal — there is no landing page (the root `/` currently serves the pricing page directly via the marketing layout), the login pages use a basic centered-card layout that doesn't communicate product value, and the pricing page lacks storage quota information which is a core monetization lever.
Additionally, the backend has no concept of storage quotas per tenant. Documents can be uploaded without limit, creating an unbounded cost liability on the file storage (TrueNAS/disk). Sprint 14 introduces a **StorageQuotaService** that enforces per-plan limits, making the pricing tiers meaningful at the infrastructure level.
### Sprint Goals
1. **Landing Page** — Create a professional homepage that converts visitors to signups
2. **Login Redesign** — Split-layout login pages that reinforce brand value during auth flow
3. **Pricing Rework** — Add storage tier information, update pricing model
4. **Storage Quota Backend** — Enforce plan-based storage limits on document uploads
---
## 2. Affected Components
| Component | Path | Role |
|-----------|------|------|
| Marketing layout | `cannamanage-frontend/src/app/(marketing)/layout.tsx` | Shared header/footer for marketing pages |
| Homepage (NEW) | `cannamanage-frontend/src/app/(marketing)/page.tsx` | Landing page — hero, features, trust signals |
| Pricing page | `cannamanage-frontend/src/app/(marketing)/pricing/page.tsx` | Pricing cards with storage tiers |
| Auth layout | `cannamanage-frontend/src/app/(auth)/layout.tsx` | Centered flex container for login |
| Admin login | `cannamanage-frontend/src/app/(auth)/login/page.tsx` | Admin/staff login form |
| Portal login | `cannamanage-frontend/src/app/(portal)/portal-login/page.tsx` | Member portal login form |
| PlanTier enum | `cannamanage-domain/src/main/java/de/cannamanage/domain/enums/PlanTier.java` | TRIAL, STARTER, PRO, ENTERPRISE |
| Club entity | `cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java` | Needs `storageUsedBytes` field |
| Document entity | `cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Document.java` | Has `fileSize` field — source of truth for usage |
| DocumentService | `cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java` | Upload logic — needs quota check |
| StorageQuotaService (NEW) | `cannamanage-service/src/main/java/de/cannamanage/service/StorageQuotaService.java` | Quota calculation and enforcement |
| StorageController (NEW) | `cannamanage-api/src/main/java/de/cannamanage/api/controller/StorageController.java` | REST endpoint for storage usage |
| Flyway V36 (NEW) | `cannamanage-api/src/main/resources/db/migration/V36__storage_quota.sql` | Add storage tracking column |
| i18n messages | `cannamanage-frontend/src/messages/de.json` | New keys for landing page, pricing storage |
| Documents frontend service | `cannamanage-frontend/src/services/documents.ts` | Needs quota-exceeded error handling |
---
## 3. Current State (Ist-Zustand)
### Marketing Pages
- **No landing page** exists at `(marketing)/page.tsx` — the root `/` route likely falls through or shows a 404
- **Pricing page** has 3 tiers (Starter €19, Pro €49, Enterprise) with member limits and feature lists, but **no storage information**
- **Marketing layout** has a sticky header with logo + "Preise" + "Anmelden" links, and a footer with Produkt/Rechtliches columns
- Navigation text is hardcoded German (not i18n) in the layout
### Login Pages
- **Auth layout** is a minimal centered flex container: `fixed inset-0 z-50 flex items-center justify-center`
- **Admin login** renders a centered card with logo, email/password form, forgot password link, and portal link
- **Portal login** is nearly identical but uses portal-specific translations and mock auth
- Both pages use the same visual pattern — no split-layout, no brand messaging during auth
### Storage Backend
- **Document entity** already tracks `fileSize` (Long) per file
- **PlanTier enum** exists: TRIAL, STARTER, PRO, ENTERPRISE
- **No storage quota concept** exists anywhere — no `storage_used_bytes` column, no quota checks on upload
- **DocumentService** handles upload/download/delete but never checks cumulative storage
- Latest Flyway migration: `V35__generated_reports_add_timestamps.sql`
---
## 4. Risk Assessment
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Landing page doesn't convert (poor copy/design) | Medium | Medium (lost signups) | Follow proven SaaS landing page patterns; iterate based on analytics |
| Storage quota breaks existing uploads | Low | High (data loss) | Implement as soft-limit first — warn but don't block for existing over-limit tenants |
| i18n key explosion | Low | Low (maintenance) | Group new keys under `marketing.home`, `marketing.pricing.storage` namespaces |
| Split login layout breaks on mobile | Medium | Medium (can't log in) | Mobile-first design: left panel hidden on `<md` breakpoints |
| Quota calculation performance (SUM query) | Low | Medium (slow uploads) | Cache quota in `storage_used_bytes` column; recalculate on upload/delete |
---
## 5. Solution Options
### Option A: Full Sprint — All 4 Areas (Recommended)
- Landing page, login redesign, pricing update, storage quota backend
- **Effort:** ~16-20 hours
- **Pros:** Complete marketing+monetization story, enables public launch
- **Cons:** Larger scope, more testing surface
### Option B: Frontend Only — Landing + Login + Pricing (No Backend)
- Skip StorageQuotaService, just update frontend
- **Effort:** ~8-10 hours
- **Pros:** Faster delivery, lower risk
- **Cons:** Storage limits are marketing fiction without enforcement
### Option C: Backend Only — Storage Quota (No Marketing)
- Implement quota enforcement, defer marketing pages
- **Effort:** ~6-8 hours
- **Pros:** Real monetization enforcement
- **Cons:** No user-facing marketing value, can't launch publicly
---
## 6. Recommendation
**Option A** — the full sprint. The four areas are interdependent: the pricing page promises storage limits that the backend must enforce, and the landing page is the entry point that drives users to pricing. Login redesign is a low-risk polish pass that significantly improves first impressions.
The storage quota backend should be designed as an **incremental counter** (update `storage_used_bytes` on upload/delete) rather than a `SUM` query on every upload — this keeps upload latency constant regardless of document count.
---
## 7. Open Questions
- [ ] Should the landing page include a product screenshot/mockup, or is an illustration-based hero preferred?
- [ ] For portal login left panel: show rotating testimonials, or static feature highlights?
- [ ] Storage overage billing (€0.15/GB/mo for Pro) — is this just displayed in pricing, or should we build the actual billing integration now?
- [ ] Free trial — is TRIAL tier (PlanTier enum already has it) time-limited? Should landing page mention trial duration?
@@ -0,0 +1,124 @@
# Code Review: Sprint 14 — Marketing & Monetization
**Datum:** 2026-06-18
**Reviewer:** Roo (Reviewer)
**Plan:** cannamanage-sprint14-plan.md v2
**Testplan:** cannamanage-sprint14-testplan.md v2
**Status:** ⚠️ Approved with comments
---
## Zusammenfassung
Implementation is solid and complete for all 5 phases. All plan components are present, i18n is complete with full DE/EN parity, the backend quota enforcement chain is correctly wired end-to-end, and the frontend properly handles 402 responses. Two warnings identified — one security-relevant deviation from plan (StorageController auth pattern) and one missing planned component (SubscriptionService tier-change hook).
## Geprüfte Dateien
| Datei | Änderung | Bewertung |
|-------|---------|-----------|
| `cannamanage-frontend/src/app/(marketing)/page.tsx` | Neu | ✅ |
| `cannamanage-frontend/src/app/(marketing)/marketing-layout-client.tsx` | Neu | ✅ |
| `cannamanage-frontend/src/app/(marketing)/layout.tsx` | Geändert | ✅ |
| `cannamanage-frontend/src/app/(auth)/layout.tsx` | Geändert | ✅ |
| `cannamanage-frontend/src/app/(auth)/login/page.tsx` | Geändert | ✅ |
| `cannamanage-frontend/src/app/(portal)/portal-login/page.tsx` | Geändert | ✅ |
| `cannamanage-frontend/src/app/(marketing)/pricing/page.tsx` | Geändert | ✅ (not reviewed in full — storage keys verified) |
| `cannamanage-frontend/messages/de.json` | Geändert | ✅ |
| `cannamanage-frontend/messages/en.json` | Geändert | ✅ |
| `cannamanage-frontend/src/services/storage.ts` | Neu | ✅ |
| `cannamanage-frontend/src/services/documents.ts` | Geändert | ✅ |
| `cannamanage-service/src/main/java/.../StorageQuotaService.java` | Neu | ✅ |
| `cannamanage-service/src/main/java/.../StorageQuotaExceededException.java` | Neu | ✅ |
| `cannamanage-api/src/main/java/.../StorageController.java` | Neu | ⚠️ |
| `cannamanage-api/src/main/java/.../GlobalExceptionHandler.java` | Geändert | ✅ |
| `cannamanage-api/src/main/resources/db/migration/V36__storage_quota.sql` | Neu | ✅ |
| `cannamanage-domain/src/main/java/.../Club.java` | Geändert | ✅ |
| `cannamanage-service/src/main/java/.../DocumentService.java` | Geändert | ✅ |
## Checkliste
| # | Prüfpunkt | Ergebnis | Anmerkung |
|---|-----------|----------|-----------|
| 1 | Plan-Konformität | ⚠️ | StorageController uses @RequestParam instead of JWT extraction (Step 4.5). SubscriptionService hook (Step 4.9) not implemented. |
| 2 | Kein Extra-Scope | ✅ | No scope creep detected |
| 3 | Bestehende Patterns korrekt | ✅ | Follows existing codebase conventions (constructor injection, @Slf4j, RFC 9457 ProblemDetail) |
| 4 | i18n vollständig (de + en) | ✅ | 26 `marketing.home` keys, 10 `marketing.nav` keys, 16 `marketing.pricing.storage` keys, 10 `comparison` keys, FAQ storage — all present in both DE and EN with zero mismatches |
| 5 | StorageQuotaExceededException separat | ✅ | Separate class with incompatible constructor (long, long, long). QuotaExceededException maps to 409, StorageQuotaExceededException maps to 402 — correct separation. |
| 6 | V36 Migration korrekt | ✅ | Non-destructive (`ADD COLUMN IF NOT EXISTS`), correct default (5 GB = 5368709120), backfill via SUM(documents.file_size) |
| 7 | Frontend responsive + dark/light | ✅ | Landing page uses responsive grid (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`), auth layouts use `hidden md:flex`, proper use of Tailwind dark mode utilities |
| 8 | Error handling (402 on quota exceeded) | ✅ | Full chain: StorageQuotaExceededException → GlobalExceptionHandler 402 → documents.ts catches status 402 → throws typed error with problemDetail |
## Befunde
### ⚠️ WARNING-1: StorageController auth deviation from plan
**Datei:** [`StorageController.java`](cannamanage-api/src/main/java/de/cannamanage/api/controller/StorageController.java:29)
**Plan (Step 4.5)** specified:
```java
@GetMapping("/usage")
public ResponseEntity<StorageUsageDTO> getUsage(@AuthenticationPrincipal UserDetails user) {
UUID clubId = extractClubId(user);
// ...
}
```
**Actual implementation:**
```java
@GetMapping("/usage")
public ResponseEntity<StorageUsageDTO> getUsage(@RequestParam UUID clubId) {
return ResponseEntity.ok(storageQuotaService.getUsage(clubId));
}
```
- **Risiko:** Any authenticated ADMIN/STAFF user can query any club's storage usage by passing an arbitrary `clubId`. The plan's approach enforced tenant isolation by deriving the club from the JWT token.
- **Empfehlung:** Replace `@RequestParam UUID clubId` with `@AuthenticationPrincipal` extraction pattern used elsewhere (e.g., DocumentController). This ensures users can only see their own club's data. If cross-club access is intentional (admin panel use case), add a separate admin-only endpoint.
---
### ⚠️ WARNING-2: SubscriptionService tier-change hook not implemented
**Plan (Step 4.9)** specified a `SubscriptionService.onTierChange(UUID clubId, PlanTier newTier)` method to update `storage_limit_bytes` when a club changes tier.
- **Not found** in the implementation.
- **Impact:** Low — the plan's own note says "Full subscription management (Stripe webhooks, billing cycle, trial-to-paid) is deferred to a future sprint." The hook would currently be dead code.
- **Empfehlung:** Acceptable to defer. When subscription management is implemented, this hook must be added. The `StorageQuotaService.getLimitForTier()` already exists to support it.
---
### ️ INFO-1: getLimitForTier takes String instead of PlanTier enum
**Datei:** [`StorageQuotaService.java`](cannamanage-service/src/main/java/de/cannamanage/service/StorageQuotaService.java:86)
Plan specified `getLimitForTier(PlanTier tier)` but implementation uses `getLimitForTier(String tier)` with a switch on lowercase string matching. This is pragmatic since no `PlanTier` enum exists yet in the domain module. When the enum is created, update the method signature.
---
### ️ INFO-2: Marketing layout extracted to client component
**Datei:** [`marketing-layout-client.tsx`](cannamanage-frontend/src/app/(marketing)/marketing-layout-client.tsx:1)
The plan didn't explicitly specify this separation, but it's a correct architectural choice: the server-side `layout.tsx` handles `getMessages()` and wraps with `NextIntlClientProvider`, while the client component handles the actual rendering with `useTranslations`. This follows Next.js 14 best practices for server/client component boundaries.
---
### ️ INFO-3: Frontend storage service uses apiClient with clubId param
**Datei:** [`storage.ts`](cannamanage-frontend/src/services/storage.ts:12)
The frontend `getStorageUsage(clubId: string)` matches the backend's `@RequestParam UUID clubId`. This is consistent — but both should be updated together if WARNING-1 is addressed (the frontend would then not need to pass clubId explicitly).
## Tests
- **Backend-Tests ausgeführt:** Nicht im Scope dieses Reviews (kein Build-Ausführung angefordert)
- **Testplan T-14 bis T-32:** Backend unit/integration tests not yet verified as passing
- **E2E T-01 bis T-13:** Frontend E2E tests not executed in this review
## Empfehlung
**⚠️ Approved with comments** — merge is acceptable, but WARNING-1 (StorageController tenant isolation) should be addressed before production deployment. WARNING-2 (SubscriptionService hook) is acceptable to defer.
### Prioritized actions:
1. **Before production:** Fix StorageController to use JWT-based club extraction instead of `@RequestParam`
2. **Next sprint:** Add SubscriptionService tier-change hook when billing features are implemented
3. **Housekeeping:** Introduce `PlanTier` enum and update `getLimitForTier` signature
@@ -0,0 +1,86 @@
# Plan Review: Sprint 14 — Marketing & Monetization
**Date:** 2026-06-18
**Module:** cannamanage (full-stack)
**Reviewer:** Lumen (Plan Reviewer)
**Documents:** analysis v1, plan v2, testplan v2
**Verdict:** ✅ APPROVED
---
## Summary
Re-review after v1 findings were addressed. All 4 findings (1 blocker, 3 warnings) from the v1 review have been properly resolved in v2. The plan is complete, correct, and ready for implementation.
## v1 Finding Resolution
| # | v1 Finding | Resolution in v2 | Status |
|---|-----------|------------------|--------|
| 1 | ❌ `QuotaExceededException` naming conflict | Renamed to `StorageQuotaExceededException` in Step 4.4 with explicit note about existing class | ✅ Fixed |
| 2 | ⚠️ No sync for `storage_limit_bytes` on tier change | Added Step 4.9 `onTierChange()` hook in `SubscriptionService` | ✅ Fixed |
| 3 | ⚠️ English i18n keys not specified | Step 1.3 now states both `de.json` and `en.json` must receive equivalent keys; T-32 tests both | ✅ Fixed |
| 4 | ⚠️ `extractClubId(user)` not defined | Step 4.5 now has full implementation with Javadoc referencing `DocumentController` pattern | ✅ Fixed |
## Reviewed Documents
| Document | Version | Assessment |
|----------|---------|-----------|
| Analysis | v1 | ✅ |
| Plan | v2 | ✅ |
| Testplan | v2 | ✅ |
## Checklist
### Assessment
| # | Check | Result | Note |
|---|-------|--------|------|
| 1 | Problem statement complete | ✅ | Clear: no landing page, no quota enforcement, basic login UI |
| 2 | Affected components identified | ✅ | 15 components listed (was 14, +1 for new `StorageQuotaExceededException`) |
| 3 | Current state accurate | ✅ | Confirmed: no `(marketing)/page.tsx`, nav is hardcoded, V35 is latest migration |
| 4 | Risk assessment realistic | ✅ | 5 risks with appropriate mitigations |
| 5 | Solution options evaluated | ✅ | 3 options with effort estimates, Option A justified |
### Implementation Plan
| # | Check | Result | Note |
|---|-------|--------|------|
| 6 | All requirements covered | ✅ | All 4 areas: landing, login, pricing, storage quota |
| 7 | Correct patterns referenced | ✅ | `NextIntlClientProvider`, Spring `@Service`, `@PreAuthorize`, `CustomUserDetails` cast pattern |
| 8 | File paths correct | ✅ | All verified against codebase |
| 9 | Implementation order logical | ✅ | Frontend (phases 1-3) → backend (phase 4) → integration (phase 5) |
| 10 | No gaps in steps | ✅ | Migration → entity → service → controller → exception → security config → tier-sync — complete chain |
| 11 | Flyway migrations planned | ✅ | V36 correct next number, H2-only appropriate for this project |
| 12 | Error handling planned | ✅ | 402 with RFC 9457 ProblemDetail, floor-at-zero for decrements |
| 13 | No scope creep | ✅ | Explicitly defers overage billing and email notifications |
### Testplan
| # | Check | Result | Note |
|---|-------|--------|------|
| 14 | Coverage complete | ✅ | Every plan step has ≥1 test. 32 tests across 4 areas. |
| 15 | Test types appropriate | ✅ | E2E for UI (Playwright), Unit for service logic, Integration for controllers |
| 16 | Edge cases covered | ✅ | Floor-at-zero (T-19), exactly-at-limit (T-15c, T-16b), near-limit thresholds |
| 17 | Test class naming correct | ✅ | `StorageQuotaServiceTest`, `StorageControllerTest`, `DocumentServiceTest` |
| 18 | Test method naming correct | ✅ | `testGetUsage_calculatesCorrectly()`, `testCheckQuota_overLimit_throwsQuotaExceeded()` |
| 19 | Test data defined | ✅ | Explicit UUIDs, byte values, and preconditions documented |
| 20 | SSH/manual tests identified | N/A | Not a PAISY project |
## Traceability Matrix
| Acceptance Criterion | Plan Step | Test Case(s) | Status |
|---------------------|-----------|-------------|--------|
| AC1: Landing page renders | Step 1.1 | T-01 | ✅ Covered |
| AC2: Landing responsive + dark/light | Step 1.1 | T-02, T-03 | ✅ Covered |
| AC3: Admin login split layout | Steps 2.1, 2.2 | T-06, T-07, T-08 | ✅ Covered |
| AC4: Portal login member-themed | Step 2.3 | T-09, T-10 | ✅ Covered |
| AC5: Pricing shows storage | Steps 3.1-3.3 | T-11, T-12 | ✅ Covered |
| AC6: Pricing FAQ storage | Step 3.4 | T-13 | ✅ Covered |
| AC7: GET /api/v1/storage/usage | Steps 4.3, 4.5 | T-23, T-24, T-25 | ✅ Covered |
| AC8: Upload rejected 402 | Steps 4.3, 4.6, 4.7 | T-16, T-27, T-31 | ✅ Covered |
| AC9: Delete decrements counter | Step 4.6 | T-18, T-28 | ✅ Covered |
| AC10: Backfill on migration | Step 4.1 | T-29, T-30 | ✅ Covered |
## Verdict
**✅ APPROVED** — All 20 checklist items pass. All 4 v1 findings resolved. Plan is complete, correct, and ready for implementation. Recommend GO.
+571
View File
@@ -0,0 +1,571 @@
# Plan: Sprint 14 — Marketing & Monetization
**Date:** 2026-06-18
**Author:** Patrick Plate / Lumen (Planner)
**Status:** v2
**Basis:** cannamanage-sprint14-analysis.md
---
## Background
Sprint 14 transforms CannaManage from an internal tool into a market-ready SaaS product. It delivers four interconnected pieces: a converting landing page, premium-feeling login experiences, an updated pricing page with storage tiers, and the backend enforcement that makes storage quotas real. Together these enable the public launch.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Frontend — Marketing Layer │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ (marketing)/page.tsx ──► Landing page (hero, features, CTA) │
│ (marketing)/pricing/page.tsx ──► Updated with storage tiers │
│ (marketing)/layout.tsx ──► Updated nav (Features link) │
│ │
├─────────────────────────────────────────────────────────────────────┤
│ Frontend — Auth Layer │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ (auth)/layout.tsx ──► Split layout (branding left, form right) │
│ (auth)/login/page.tsx ──► Form-only (layout handles split) │
│ (portal)/portal-login/page.tsx ──► Member-themed split login │
│ │
├─────────────────────────────────────────────────────────────────────┤
│ Backend — Storage Quota │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ StorageQuotaService ──► getUsage(clubId), checkQuota(clubId, size) │
│ StorageController ──► GET /api/v1/storage/usage │
│ DocumentService ──► Pre-upload quota check │
│ Club entity ──► storageUsedBytes column (incremental counter) │
│ V36 migration ──► ALTER TABLE clubs ADD storage_used_bytes │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Components
| # | Component | Module | Action |
|---|-----------|--------|--------|
| 1 | Landing page | cannamanage-frontend | **New**`(marketing)/page.tsx` |
| 2 | Auth layout (split) | cannamanage-frontend | **Modify**`(auth)/layout.tsx` |
| 3 | Admin login page | cannamanage-frontend | **Modify** — remove layout wrapper, form only |
| 4 | Portal login page | cannamanage-frontend | **Modify** — member-themed split variant |
| 5 | Pricing page | cannamanage-frontend | **Modify** — add storage tiers, update model |
| 6 | Marketing layout | cannamanage-frontend | **Modify** — add "Features" nav link |
| 7 | i18n messages (de) | cannamanage-frontend | **Modify** — add marketing.home, pricing.storage keys |
| 8 | i18n messages (en) | cannamanage-frontend | **Modify** — full English equivalents for marketing.home.* and marketing.pricing.storage.* |
| 9 | StorageQuotaService | cannamanage-service | **New** — quota logic |
| 10 | StorageController | cannamanage-api | **New** — REST endpoint |
| 11 | Club entity | cannamanage-domain | **Modify** — add storageUsedBytes |
| 12 | DocumentService | cannamanage-service | **Modify** — add quota check on upload |
| 13 | Flyway V36 | cannamanage-api | **New** — migration SQL |
| 14 | Frontend storage service | cannamanage-frontend | **New**`services/storage.ts` |
| 15 | StorageQuotaExceededException | cannamanage-service | **New** — 402 storage exception |
---
## Implementation Steps
### Phase 1: Landing Page
#### Step 1.1 — Create `(marketing)/page.tsx`
**File:** `cannamanage-frontend/src/app/(marketing)/page.tsx`
A full landing page with these sections:
- **Hero** — Headline ("Die smarte Verwaltung für deinen Anbauverein"), subheadline, primary CTA (→ /pricing), secondary CTA (→ /login)
- **Feature grid** — 6 cards in a 2×3 / 3×2 responsive grid:
1. Compliance Tracking (§22 KCanG documentation)
2. Grow Management (calendar, stages, sensors)
3. Member Portal (self-service, history, profile)
4. Distribution Quotas (25g/day, 50g/month enforcement)
5. Document Archive (GoBD-compliant, retention periods)
6. Financial Management (fees, SEPA, bank import)
- **Trust signals** — "Für Anbauvereine in Deutschland" badge, CanVerG compliance, DSGVO/GoBD, TLS encryption
- **Final CTA** — "Jetzt kostenlos testen" → /pricing
Use `lucide-react` icons. All text via `useTranslations("marketing.home")`. Responsive: single column on mobile, grid on tablet+. Dark/light mode compatible.
#### Step 1.2 — Update marketing layout navigation
**File:** `cannamanage-frontend/src/app/(marketing)/layout.tsx`
- Add "Features" link in header nav (scrolls to `#features` anchor on homepage, or links to `/#features`)
- Internationalize hardcoded "Preise" and "Anmelden" strings via `useTranslations`
- Add "Features" link to footer "Produkt" column
#### Step 1.3 — Add i18n keys for landing page (de + en)
**Files:**
- `cannamanage-frontend/messages/de.json`
- `cannamanage-frontend/messages/en.json`
Both locale files must receive entries for the `marketing.home.*` and `marketing.pricing.storage.*` namespaces. The German keys are the primary source; the English file must contain equivalent translations for all keys.
Add under `marketing.home` (German example):
```json
{
"marketing": {
"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"
}
}
}
```
---
### Phase 2: Login Redesign
#### Step 2.1 — Redesign auth layout to split-layout
**File:** `cannamanage-frontend/src/app/(auth)/layout.tsx`
Replace the simple centered container with a split layout:
```
┌──────────────────────────────────────────────────┐
│ Left Panel (hidden <md) │ Right Panel (form) │
│ │ │
│ • App logo + name │ {children} │
│ • Tagline │ │
│ • 3 feature highlights │ │
│ • Background gradient │ │
│ │ │
└──────────────────────────────────────────────────┘
```
- Left panel: `hidden md:flex md:w-1/2 lg:w-[55%]` — dark gradient background with primary color accent
- Right panel: `w-full md:w-1/2 lg:w-[45%] flex items-center justify-center p-8`
- Left panel content: CannaManage logo, "Dein Verein, digital verwaltet" tagline, 3 bullet points with icons (Compliance, Mitglieder, Abgaben)
- Still wraps `{children}` in `NextIntlClientProvider`
#### Step 2.2 — Adjust admin login page
**File:** `cannamanage-frontend/src/app/(auth)/login/page.tsx`
- Remove the logo/branding section (now in layout's left panel)
- Keep only the form card itself: title "Anmelden", email, password, submit, forgot password, portal link
- The page becomes purely the form — layout handles the split
#### Step 2.3 — Create portal login with member theming
**File:** `cannamanage-frontend/src/app/(portal)/portal-login/page.tsx`
Portal login gets its own split layout inline (since it's in a different route group):
- Left panel: member-focused messaging — "Willkommen zurück", "Dein persönlicher Bereich", icons for Abgabehistorie/Profil/Dokumente
- Right panel: login form (same structure as admin but portal-specific translations)
- Visual differentiation: left panel uses a slightly different gradient (e.g., emerald/teal tint vs. primary green)
- Full-page layout (no separate layout.tsx needed — inline the split)
---
### Phase 3: Pricing Page Update
#### Step 3.1 — Update pricing data model
**File:** `cannamanage-frontend/src/app/(marketing)/pricing/page.tsx`
Update the `plans` array to include storage:
```typescript
const plans = [
{
id: "starter",
icon: Leaf,
price: "19",
memberLimit: "30",
storage: "5", // GB
features: [
"memberManagement",
"distributionTracking",
"complianceReports",
"quotaMonitoring",
"memberPortal",
"emailSupport",
],
},
{
id: "pro",
icon: Cannabis,
price: "49",
memberLimit: "100",
storage: "50", // GB
storageOverage: "0.15", // €/GB/month
popular: true,
features: [
"allStarter",
"growCalendar",
"staffManagement",
"advancedReports",
"pdfExport",
"apiAccess",
"prioritySupport",
],
},
{
id: "enterprise",
icon: Building2,
price: null,
memberLimit: "unlimited",
storage: "custom",
features: [
"allPro",
"unlimitedMembers",
"multiClub",
"customIntegrations",
"sla",
"dedicatedSupport",
"onboarding",
],
},
]
```
#### Step 3.2 — Render storage in plan cards
In each plan card, add a storage badge below the member limit:
- Starter: "5 GB Speicher"
- Pro: "50 GB Speicher" + small note "(danach 0,15 €/GB/Monat)"
- Enterprise: "Individueller Speicher"
#### Step 3.3 — Add storage row to feature comparison
Below the plan cards, add a comparison table section:
| Feature | Starter | Pro | Enterprise |
|---------|---------|-----|------------|
| Mitglieder | 30 | 100 | Unbegrenzt |
| Speicher | 5 GB | 50 GB | Individuell |
| Überschreitung | Upgrade erforderlich | 0,15 €/GB/Mo | — |
| Grow-Kalender | — | ✓ | ✓ |
| API-Zugang | — | ✓ | ✓ |
| Multi-Club | — | — | ✓ |
#### Step 3.4 — Add FAQ entry about storage
Add to the `faqs` array:
```typescript
{ id: "storage" } // "Was passiert wenn mein Speicherplatz voll ist?"
```
i18n keys:
- `marketing.pricing.faq.storage.question`: "Was passiert, wenn mein Speicher voll ist?"
- `marketing.pricing.faq.storage.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."
---
### Phase 4: Storage Quota Backend
#### Step 4.1 — Flyway migration V36
**File:** `cannamanage-api/src/main/resources/db/migration/V36__storage_quota.sql`
```sql
-- Add storage tracking to clubs
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS storage_used_bytes BIGINT DEFAULT 0;
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS storage_limit_bytes BIGINT DEFAULT 5368709120;
-- Default: 5 GB (5 * 1024^3) = Starter tier
-- Backfill existing clubs with actual usage
UPDATE clubs c SET storage_used_bytes = COALESCE(
(SELECT SUM(d.file_size) FROM documents d WHERE d.club_id = c.id), 0
);
```
#### Step 4.2 — Update Club entity
**File:** `cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java`
Add fields:
```java
@Column(name = "storage_used_bytes", nullable = false)
private Long storageUsedBytes = 0L;
@Column(name = "storage_limit_bytes", nullable = false)
private Long storageLimitBytes = 5_368_709_120L; // 5 GB default
```
With getters/setters.
#### Step 4.3 — Create StorageQuotaService
**File:** `cannamanage-service/src/main/java/de/cannamanage/service/StorageQuotaService.java`
```java
@Service
@Slf4j
public class StorageQuotaService {
private final ClubRepository clubRepository;
// Plan tier limits
private static final long STARTER_LIMIT = 5L * 1024 * 1024 * 1024; // 5 GB
private static final long PRO_LIMIT = 50L * 1024 * 1024 * 1024; // 50 GB
private static final long ENTERPRISE_LIMIT = Long.MAX_VALUE; // Unlimited
public StorageUsageDTO getUsage(UUID clubId) {
Club club = clubRepository.findById(clubId).orElseThrow();
long used = club.getStorageUsedBytes();
long limit = club.getStorageLimitBytes();
double percentage = limit > 0 ? (double) used / limit * 100 : 0;
return new StorageUsageDTO(used, limit, percentage);
}
public void checkQuota(UUID clubId, long additionalBytes) {
Club club = clubRepository.findById(clubId).orElseThrow();
long newTotal = club.getStorageUsedBytes() + additionalBytes;
if (newTotal > club.getStorageLimitBytes()) {
throw new StorageQuotaExceededException(club.getStorageUsedBytes(),
club.getStorageLimitBytes(), additionalBytes);
}
}
public void incrementUsage(UUID clubId, long bytes) {
Club club = clubRepository.findById(clubId).orElseThrow();
club.setStorageUsedBytes(club.getStorageUsedBytes() + bytes);
clubRepository.save(club);
}
public void decrementUsage(UUID clubId, long bytes) {
Club club = clubRepository.findById(clubId).orElseThrow();
long newUsage = Math.max(0, club.getStorageUsedBytes() - bytes);
club.setStorageUsedBytes(newUsage);
clubRepository.save(club);
}
public static long getLimitForTier(PlanTier tier) {
return switch (tier) {
case TRIAL, STARTER -> STARTER_LIMIT;
case PRO -> PRO_LIMIT;
case ENTERPRISE -> ENTERPRISE_LIMIT;
};
}
public boolean isNearLimit(UUID clubId, int thresholdPercent) {
StorageUsageDTO usage = getUsage(clubId);
return usage.percentage() >= thresholdPercent;
}
}
```
#### Step 4.4 — Create StorageQuotaExceededException
**File:** `cannamanage-service/src/main/java/de/cannamanage/service/exception/StorageQuotaExceededException.java`
> **Note:** The existing `QuotaExceededException` (at `cannamanage-service/src/main/java/de/cannamanage/service/exception/QuotaExceededException.java`) is reserved for CanG distribution quotas (25g/day, 50g/month) and takes a `QuotaViolationCode`. The storage exception needs its own class with an incompatible constructor signature.
```java
public class StorageQuotaExceededException extends RuntimeException {
private final long currentUsage;
private final long limit;
private final long requestedBytes;
public StorageQuotaExceededException(long currentUsage, long limit, long requestedBytes) {
super("Storage quota exceeded: current=%d, limit=%d, requested=%d"
.formatted(currentUsage, limit, requestedBytes));
this.currentUsage = currentUsage;
this.limit = limit;
this.requestedBytes = requestedBytes;
}
// Getters
}
```
Map in `GlobalExceptionHandler` to HTTP 402 Payment Required with RFC 9457 problem detail.
#### Step 4.5 — Create StorageController
**File:** `cannamanage-api/src/main/java/de/cannamanage/api/controller/StorageController.java`
```java
@RestController
@RequestMapping("/api/v1/storage")
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')")
public class StorageController {
private final StorageQuotaService storageQuotaService;
@GetMapping("/usage")
public ResponseEntity<StorageUsageDTO> getUsage(@AuthenticationPrincipal UserDetails user) {
UUID clubId = extractClubId(user);
return ResponseEntity.ok(storageQuotaService.getUsage(clubId));
}
/**
* Extracts the clubId from the authenticated user's JWT claims.
* The user's club association is stored as a "clubId" claim in the token,
* set during authentication by AuthService. This follows the same pattern
* used in DocumentController and other club-scoped controllers.
*/
private UUID extractClubId(UserDetails user) {
// Cast to our CustomUserDetails which carries the clubId from JWT
var customUser = (CustomUserDetails) user;
return customUser.getClubId();
}
}
```
Response DTO:
```java
public record StorageUsageDTO(long usedBytes, long limitBytes, double percentage) {}
```
#### Step 4.6 — Update DocumentService for quota check
**File:** `cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java`
In the upload method, add before file write:
```java
// Check storage quota before upload
storageQuotaService.checkQuota(clubId, file.getSize());
// ... existing upload logic ...
// After successful save, increment usage counter
storageQuotaService.incrementUsage(clubId, file.getSize());
```
In the delete method, add after file removal:
```java
// Decrement usage counter after successful delete
storageQuotaService.decrementUsage(clubId, document.getFileSize());
```
#### Step 4.7 — Handle 402 in GlobalExceptionHandler
**File:** `cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java`
Add handler:
```java
@ExceptionHandler(StorageQuotaExceededException.class)
public ResponseEntity<ProblemDetail> handleStorageQuotaExceeded(StorageQuotaExceededException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.PAYMENT_REQUIRED);
problem.setTitle("Storage Quota Exceeded");
problem.setDetail("Upload would exceed storage limit. Current: " + ex.getCurrentUsage()
+ " bytes, Limit: " + ex.getLimit() + " bytes, Requested: " + ex.getRequestedBytes() + " bytes");
problem.setProperty("currentUsage", ex.getCurrentUsage());
problem.setProperty("limit", ex.getLimit());
problem.setProperty("requestedBytes", ex.getRequestedBytes());
return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED).body(problem);
}
```
#### Step 4.8 — Add SecurityConfig matcher for storage endpoint
**File:** `cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java`
Add:
```java
.requestMatchers(HttpMethod.GET, "/api/v1/storage/**").hasAnyRole("ADMIN", "STAFF")
```
#### Step 4.9 — Subscription tier change hook (storage_limit_bytes sync)
**File:** `cannamanage-service/src/main/java/de/cannamanage/service/SubscriptionService.java`
When a club's subscription tier changes (e.g., Starter→Pro), `storage_limit_bytes` must be updated to match the new tier. Add an `onTierChange()` hook:
```java
/**
* Called when a club upgrades/downgrades their subscription tier.
* Updates the storage_limit_bytes to match the new tier's allocation.
*/
public void onTierChange(UUID clubId, PlanTier newTier) {
Club club = clubRepository.findById(clubId).orElseThrow();
long newLimit = StorageQuotaService.getLimitForTier(newTier);
club.setStorageLimitBytes(newLimit);
clubRepository.save(club);
log.info("Club {} storage limit updated to {} bytes (tier: {})", clubId, newLimit, newTier);
}
```
> **Note:** This is a minimal hook. Full subscription management (Stripe webhooks, billing cycle, trial-to-paid) is deferred to a future sprint. For now, this method is called from the admin panel when manually changing a club's tier.
---
### Phase 5: Frontend Storage Integration
#### Step 5.1 — Create storage service
**File:** `cannamanage-frontend/src/services/storage.ts`
```typescript
export interface StorageUsage {
usedBytes: number
limitBytes: number
percentage: number
}
export async function getStorageUsage(): Promise<StorageUsage> {
const response = await fetch('/api/v1/storage/usage', { ... })
return response.json()
}
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]
}
```
#### Step 5.2 — Handle 402 in document upload UI
**File:** `cannamanage-frontend/src/services/documents.ts`
In the upload handler, catch 402 responses and display a quota-exceeded toast/dialog with upgrade CTA pointing to `/pricing`.
---
## Open Questions
- [ ] Should storage overage (Pro tier, €0.15/GB/mo) auto-allow uploads beyond limit, or still block?
- **Recommendation:** For now, block at limit for all tiers. Overage billing is a future sprint (requires Stripe integration).
- [ ] Email notifications at 80%/95% — implement the hook in this sprint or defer?
- **Recommendation:** Implement the detection (`isNearLimit`), log a warning, defer actual email sending.
---
## Acceptance Criteria
1. Landing page renders at `/` with hero, 6 features, trust signals, and CTA
2. Landing page is responsive (mobile/tablet/desktop) and supports dark/light mode
3. Admin login at `/login` shows split layout on desktop, full-width form on mobile
4. Portal login at `/portal-login` shows member-themed split layout
5. Pricing page shows storage limits per tier and comparison table with storage row
6. Pricing page has FAQ entry explaining storage limits
7. `GET /api/v1/storage/usage` returns `{ usedBytes, limitBytes, percentage }` for authenticated users
8. Document upload is rejected with HTTP 402 when quota would be exceeded
9. Document deletion decrements the storage counter
10. Existing clubs have their `storage_used_bytes` backfilled on migration
@@ -0,0 +1,596 @@
# Testplan: Sprint 14 — Marketing & Monetization
**Date:** 2026-06-18
**Author:** Patrick Plate / Lumen (Planner)
**Status:** v2
**Basis:** cannamanage-sprint14-plan.md
---
## Test Overview
| ID | Description | Type | Class/Tool | Status |
|----|-------------|------|------------|--------|
| T-01 | Landing page renders all sections | E2E | Playwright | ⬜ |
| T-02 | Landing page responsive (mobile) | E2E | Playwright | ⬜ |
| T-03 | Landing page dark/light mode | E2E | Playwright | ⬜ |
| T-04 | Landing page CTA links work | E2E | Playwright | ⬜ |
| T-05 | Marketing nav shows Features link | E2E | Playwright | ⬜ |
| T-06 | Admin login — split layout on desktop | E2E | Playwright | ⬜ |
| T-07 | Admin login — full-width on mobile | E2E | Playwright | ⬜ |
| T-08 | Admin login — form still functional | E2E | Playwright | ⬜ |
| T-09 | Portal login — member-themed split | E2E | Playwright | ⬜ |
| T-10 | Portal login — form functional | E2E | Playwright | ⬜ |
| T-11 | Pricing — storage tiers displayed | E2E | Playwright | ⬜ |
| T-12 | Pricing — comparison table with storage row | E2E | Playwright | ⬜ |
| T-13 | Pricing — FAQ storage entry visible | E2E | Playwright | ⬜ |
| T-14 | Storage usage — correct calculation | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-15 | Storage quota — allows upload under limit | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-16 | Storage quota — rejects upload over limit | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-17 | Storage quota — increment on upload | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-18 | Storage quota — decrement on delete | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-19 | Storage quota — decrement floors at zero | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-20 | Storage quota — tier limit mapping | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-21 | Storage quota — near-limit detection (80%) | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-22 | Storage quota — near-limit detection (95%) | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-23 | GET /api/v1/storage/usage — authenticated | Integration | `StorageControllerTest` | ⬜ |
| T-24 | GET /api/v1/storage/usage — unauthenticated 401 | Integration | `StorageControllerTest` | ⬜ |
| T-25 | GET /api/v1/storage/usage — correct DTO shape | Integration | `StorageControllerTest` | ⬜ |
| T-26 | Document upload — quota check integrated | Integration | `DocumentServiceTest` | ⬜ |
| T-27 | Document upload — 402 on quota exceeded | Integration | `DocumentControllerTest` | ⬜ |
| T-28 | Document delete — usage decremented | Integration | `DocumentServiceTest` | ⬜ |
| T-29 | Flyway V36 — migration applies cleanly | Integration | Flyway boot test | ⬜ |
| T-30 | Flyway V36 — backfill calculates correctly | Integration | SQL verification | ⬜ |
| T-31 | StorageQuotaExceededException — 402 response format | Unit | `GlobalExceptionHandlerTest` | ⬜ |
| T-32 | i18n — all marketing.home keys resolve (de + en) | Unit | Lint / next-intl | ⬜ |
Status: ⬜ Open | ✅ Passed | ❌ Failed | ⏭️ Skipped
---
## Test Cases
### T-01: Landing Page Renders All Sections
**Type:** E2E
**Tool:** Playwright
**Script:** `e2e/marketing/landing-page.spec.ts`
**Preconditions:**
- App running at localhost:3000
- No authentication required (public page)
**Scenarios:**
| # | Action | Expected Result |
|---|--------|-----------------|
| a | Navigate to `/` | Page loads without errors |
| b | Check hero section | Headline text visible, CTA buttons present |
| c | Check feature grid | 6 feature cards visible with titles and descriptions |
| d | Check trust signals | At least 4 trust badges visible |
| e | Check final CTA | "Kostenlos testen" button visible |
**Postconditions:**
- All i18n keys resolve (no raw key strings visible)
- No console errors
---
### T-02: Landing Page Responsive (Mobile)
**Type:** E2E
**Tool:** Playwright (viewport: 375×812)
**Scenarios:**
| # | Viewport | Expected Result |
|---|----------|-----------------|
| a | 375×812 (iPhone) | Feature grid stacks to single column |
| b | 375×812 | Hero section full-width, text wraps cleanly |
| c | 768×1024 (iPad) | Feature grid shows 2 columns |
| d | 1280×720 (desktop) | Feature grid shows 3 columns |
---
### T-03: Landing Page Dark/Light Mode
**Type:** E2E
**Tool:** Playwright (`colorScheme: 'dark'` / `'light'`)
**Scenarios:**
| # | Mode | Expected Result |
|---|------|-----------------|
| a | Dark | Background is dark, text is light, no contrast issues |
| b | Light | Background is light, text is dark, cards have proper borders |
| c | Switch | Toggle theme mid-page — re-renders correctly |
---
### T-04: Landing Page CTA Links
**Type:** E2E
**Tool:** Playwright
**Scenarios:**
| # | Element | Expected Navigation |
|---|---------|-------------------|
| a | Primary CTA ("Preise ansehen") | Navigates to `/pricing` |
| b | Secondary CTA ("Jetzt anmelden") | Navigates to `/login` |
| c | Final CTA ("Kostenlos testen") | Navigates to `/pricing` |
| d | Header "Features" link | Scrolls to `#features` section or navigates to `/#features` |
---
### T-05: Marketing Nav Features Link
**Type:** E2E
**Tool:** Playwright
**Scenarios:**
| # | Page | Expected |
|---|------|----------|
| a | `/` | Header shows "Features" link |
| b | `/pricing` | Header shows "Features" link |
| c | Click "Features" from `/pricing` | Navigates to homepage features section |
---
### T-06: Admin Login — Split Layout Desktop
**Type:** E2E
**Tool:** Playwright (viewport: 1280×720)
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | Left panel visible | Branding panel with logo, tagline, feature bullets visible |
| b | Right panel visible | Login form visible |
| c | Layout proportions | Left panel ~55%, right panel ~45% |
| d | Left panel content | "CannaManage" text, tagline, 3 feature highlights with icons |
---
### T-07: Admin Login — Full-Width Mobile
**Type:** E2E
**Tool:** Playwright (viewport: 375×812)
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | Left panel | Hidden (`hidden md:flex`) |
| b | Form panel | Full width, centered vertically |
| c | Form usability | All fields accessible, submit button tappable |
---
### T-08: Admin Login — Form Still Functional
**Type:** E2E
**Tool:** Playwright
**Preconditions:**
- Backend running with test credentials available
**Scenarios:**
| # | Input | Expected |
|---|-------|----------|
| a | Valid credentials | Redirects to `/dashboard` |
| b | Invalid password | Error message displayed |
| c | Empty fields | Validation errors shown |
---
### T-09: Portal Login — Member-Themed Split
**Type:** E2E
**Tool:** Playwright (viewport: 1280×720)
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | Left panel | Member-specific messaging ("Willkommen zurück") |
| b | Visual theme | Different gradient than admin login (teal/emerald vs. primary green) |
| c | Feature bullets | Member-relevant: Abgabehistorie, Profil, Dokumente |
---
### T-10: Portal Login — Form Functional
**Type:** E2E
**Tool:** Playwright
**Scenarios:**
| # | Input | Expected |
|---|-------|----------|
| a | Submit form | Redirects to `/portal/dashboard` |
| b | Invalid input | Error message shown |
---
### T-11: Pricing — Storage Tiers Displayed
**Type:** E2E
**Tool:** Playwright
**Scenarios:**
| # | Plan | Expected Storage Display |
|---|------|------------------------|
| a | Starter card | "5 GB Speicher" visible |
| b | Pro card | "50 GB Speicher" visible + overage note |
| c | Enterprise card | "Individueller Speicher" visible |
---
### T-12: Pricing — Comparison Table
**Type:** E2E
**Tool:** Playwright
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | Table exists | Comparison table rendered below plan cards |
| b | Storage row | "Speicher" row shows 5 GB / 50 GB / Individuell |
| c | Overage row | "Überschreitung" row shows values per plan |
| d | Responsive | Table scrollable on mobile |
---
### T-13: Pricing — FAQ Storage Entry
**Type:** E2E
**Tool:** Playwright
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | FAQ section | Contains storage question |
| b | Click expand | Answer mentions Starter upgrade + Pro overage pricing |
---
### T-14: Storage Usage Calculation
**Type:** Unit
**Class:** `cannamanage-service/src/test/java/de/cannamanage/service/StorageQuotaServiceTest.java`
**Method:** `testGetUsage_calculatesCorrectly()`
**Scenarios:**
| # | Setup | Expected |
|---|-------|----------|
| a | Club with 1 GB used, 5 GB limit | `{ usedBytes: 1073741824, limitBytes: 5368709120, percentage: 20.0 }` |
| b | Club with 0 bytes used | `{ usedBytes: 0, percentage: 0.0 }` |
| c | Club with exactly limit used | `{ percentage: 100.0 }` |
---
### T-15: Quota Allows Upload Under Limit
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testCheckQuota_underLimit_noException()`
**Scenarios:**
| # | Current Usage | Limit | Upload Size | Expected |
|---|---------------|-------|-------------|----------|
| a | 1 GB | 5 GB | 100 MB | No exception |
| b | 4.9 GB | 5 GB | 50 MB | No exception (4.95 GB < 5 GB) |
| c | 0 | 5 GB | 5 GB | No exception (exactly at limit) |
---
### T-16: Quota Rejects Upload Over Limit
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testCheckQuota_overLimit_throwsQuotaExceeded()`
**Scenarios:**
| # | Current Usage | Limit | Upload Size | Expected |
|---|---------------|-------|-------------|----------|
| a | 4.9 GB | 5 GB | 200 MB | `StorageQuotaExceededException` thrown |
| b | 5 GB | 5 GB | 1 byte | `StorageQuotaExceededException` thrown |
| c | 50 GB | 50 GB | 1 KB | `StorageQuotaExceededException` thrown |
---
### T-17: Increment On Upload
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testIncrementUsage_addsBytes()`
**Scenarios:**
| # | Initial | Increment | Expected |
|---|---------|-----------|----------|
| a | 0 | 1048576 (1 MB) | 1048576 |
| b | 1000000 | 500000 | 1500000 |
---
### T-18: Decrement On Delete
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testDecrementUsage_subtractsBytes()`
**Scenarios:**
| # | Initial | Decrement | Expected |
|---|---------|-----------|----------|
| a | 5000000 | 1000000 | 4000000 |
| b | 1048576 | 1048576 | 0 |
---
### T-19: Decrement Floors at Zero
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testDecrementUsage_floorsAtZero()`
**Scenarios:**
| # | Initial | Decrement | Expected |
|---|---------|-----------|----------|
| a | 100 | 200 | 0 (not negative) |
| b | 0 | 1000 | 0 |
---
### T-20: Tier Limit Mapping
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testGetLimitForTier()`
**Scenarios:**
| # | Tier | Expected Limit |
|---|------|---------------|
| a | TRIAL | 5 GB (5368709120) |
| b | STARTER | 5 GB (5368709120) |
| c | PRO | 50 GB (53687091200) |
| d | ENTERPRISE | Long.MAX_VALUE |
---
### T-21: Near-Limit Detection 80%
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testIsNearLimit_at80Percent()`
**Scenarios:**
| # | Usage | Limit | Threshold | Expected |
|---|-------|-------|-----------|----------|
| a | 4.0 GB | 5 GB | 80% | true (80%) |
| b | 3.9 GB | 5 GB | 80% | false (78%) |
| c | 4.1 GB | 5 GB | 80% | true (82%) |
---
### T-22: Near-Limit Detection 95%
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testIsNearLimit_at95Percent()`
**Scenarios:**
| # | Usage | Limit | Threshold | Expected |
|---|-------|-------|-----------|----------|
| a | 4.75 GB | 5 GB | 95% | true (95%) |
| b | 4.7 GB | 5 GB | 95% | false (94%) |
---
### T-23: Storage Endpoint — Authenticated
**Type:** Integration
**Class:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/StorageControllerTest.java`
**Method:** `testGetUsage_authenticated_returns200()`
**Preconditions:**
- Test user with ADMIN role and known clubId
- Club has pre-set `storageUsedBytes` and `storageLimitBytes`
**Scenarios:**
| # | Auth | Expected |
|---|------|----------|
| a | Valid ADMIN JWT | 200 with `{ usedBytes, limitBytes, percentage }` |
| b | Valid STAFF JWT | 200 with correct response |
---
### T-24: Storage Endpoint — Unauthenticated
**Type:** Integration
**Class:** `StorageControllerTest`
**Method:** `testGetUsage_unauthenticated_returns401()`
**Scenarios:**
| # | Auth | Expected |
|---|------|----------|
| a | No token | 401 Unauthorized |
| b | Expired token | 401 Unauthorized |
| c | MEMBER role | 403 Forbidden |
---
### T-25: Storage Endpoint — DTO Shape
**Type:** Integration
**Class:** `StorageControllerTest`
**Method:** `testGetUsage_responseShape()`
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | JSON keys | Response contains `usedBytes`, `limitBytes`, `percentage` |
| b | Types | `usedBytes` and `limitBytes` are numbers, `percentage` is double |
| c | Percentage calculation | Matches `usedBytes / limitBytes * 100` |
---
### T-26: Document Upload — Quota Check Integrated
**Type:** Integration
**Class:** `cannamanage-service/src/test/java/de/cannamanage/service/DocumentServiceTest.java`
**Method:** `testUploadDocument_checksQuotaBeforeWrite()`
**Scenarios:**
| # | Setup | Expected |
|---|-------|----------|
| a | Club under quota, upload 1 MB | Upload succeeds, `storageUsedBytes` incremented by file size |
| b | Club at quota limit | Upload rejected with `StorageQuotaExceededException` before file write |
---
### T-27: Document Upload — 402 Response
**Type:** Integration
**Class:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/DocumentControllerTest.java`
**Method:** `testUploadDocument_quotaExceeded_returns402()`
**Preconditions:**
- Club with `storageUsedBytes = storageLimitBytes` (fully used)
**Scenarios:**
| # | Action | Expected |
|---|--------|----------|
| a | POST multipart upload (1 byte file) | 402 Payment Required |
| b | Response body | RFC 9457 ProblemDetail with `currentUsage`, `limit`, `requestedBytes` |
---
### T-28: Document Delete — Usage Decremented
**Type:** Integration
**Class:** `DocumentServiceTest`
**Method:** `testDeleteDocument_decrementsUsage()`
**Scenarios:**
| # | Setup | Expected |
|---|-------|----------|
| a | Club with 5 MB used, delete 2 MB document | `storageUsedBytes` = 3 MB |
| b | Club with 1 MB used, delete 1 MB document | `storageUsedBytes` = 0 |
---
### T-29: Flyway V36 — Migration Applies
**Type:** Integration
**Tool:** Spring Boot test context startup
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | Application starts | V36 migration applies without error |
| b | Column exists | `SELECT storage_used_bytes FROM clubs LIMIT 1` succeeds |
| c | Default value | New clubs get `storage_used_bytes = 0` and `storage_limit_bytes = 5368709120` |
---
### T-30: Flyway V36 — Backfill
**Type:** Integration
**Tool:** SQL verification after migration
**Preconditions:**
- Club exists with 3 documents (sizes: 1MB, 2MB, 3MB)
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | After migration | `storage_used_bytes` = 6291456 (6 MB = sum of document sizes) |
| b | Club with no docs | `storage_used_bytes` = 0 |
---
### T-31: StorageQuotaExceededException — 402 Format
**Type:** Unit
**Class:** `cannamanage-api/src/test/java/de/cannamanage/api/exception/GlobalExceptionHandlerTest.java`
**Method:** `testStorageQuotaExceeded_returns402WithProblemDetail()`
**Scenarios:**
| # | Input | Expected |
|---|-------|----------|
| a | `StorageQuotaExceededException(1GB, 5GB, 200MB)` | HTTP 402, title="Storage Quota Exceeded" |
| b | Response properties | Contains `currentUsage`, `limit`, `requestedBytes` numeric fields |
---
### T-32: i18n Keys Resolve
**Type:** Unit / Lint
**Tool:** next-intl compile check or custom script
**Scenarios:**
| # | Namespace | Expected |
|---|-----------|----------|
| a | `marketing.home.*` | All 20+ keys resolve in `de.json` |
| b | `marketing.home.*` | All 20+ keys resolve in `en.json` (English equivalents) |
| c | `marketing.pricing.faq.storage.*` | question + answer keys present in both locales |
| d | `marketing.pricing.plans.*.storage` | Storage labels present for each plan in both locales |
---
## Test Data
### Backend
- **Test club:** UUID `00000000-0000-0000-0000-000000000001`, `storageUsedBytes = 1073741824` (1 GB), `storageLimitBytes = 5368709120` (5 GB)
- **Test documents:** 3 documents with `fileSize` = 100MB, 200MB, 773741824 bytes (total = 1 GB)
- **Full quota club:** UUID `00000000-0000-0000-0000-000000000002`, `storageUsedBytes = storageLimitBytes = 5368709120`
### Frontend E2E
- Landing page, pricing, login pages are public — no auth setup needed for T-01 through T-13
- Login form tests (T-08, T-10) require running backend with test user `admin@gruener-daumen.de` / `TestAdmin123!`
---
## Test Coverage
| Component | Unit | Integration | E2E | Total |
|-----------|------|-------------|-----|-------|
| Landing page | 0 | 0 | 5 | 5 |
| Login redesign | 0 | 0 | 5 | 5 |
| Pricing update | 0 | 0 | 3 | 3 |
| StorageQuotaService | 9 | 0 | 0 | 9 |
| StorageController | 0 | 3 | 0 | 3 |
| DocumentService (quota) | 0 | 2 | 0 | 2 |
| DocumentController (402) | 0 | 1 | 0 | 1 |
| Flyway migration | 0 | 2 | 0 | 2 |
| Exception handling | 1 | 0 | 0 | 1 |
| i18n verification | 1 | 0 | 0 | 1 |
| **Total** | **11** | **8** | **13** | **32** |