- 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
24 KiB
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:
- Compliance Tracking (§22 KCanG documentation)
- Grow Management (calendar, stages, sensors)
- Member Portal (self-service, history, profile)
- Distribution Quotas (25g/day, 50g/month enforcement)
- Document Archive (GoBD-compliant, retention periods)
- 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
#featuresanchor 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.jsoncannamanage-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):
{
"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}inNextIntlClientProvider
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:
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:
{ 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
-- 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:
@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
@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(atcannamanage-service/src/main/java/de/cannamanage/service/exception/QuotaExceededException.java) is reserved for CanG distribution quotas (25g/day, 50g/month) and takes aQuotaViolationCode. The storage exception needs its own class with an incompatible constructor signature.
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
@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:
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:
// 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:
// 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:
@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:
.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:
/**
* 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
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.
- Recommendation: Implement the detection (
Acceptance Criteria
- Landing page renders at
/with hero, 6 features, trust signals, and CTA - Landing page is responsive (mobile/tablet/desktop) and supports dark/light mode
- Admin login at
/loginshows split layout on desktop, full-width form on mobile - Portal login at
/portal-loginshows member-themed split layout - Pricing page shows storage limits per tier and comparison table with storage row
- Pricing page has FAQ entry explaining storage limits
GET /api/v1/storage/usagereturns{ usedBytes, limitBytes, percentage }for authenticated users- Document upload is rejected with HTTP 402 when quota would be exceeded
- Document deletion decrements the storage counter
- Existing clubs have their
storage_used_bytesbackfilled on migration