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
+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