# 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 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 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 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 { 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