dad798a904
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
572 lines
24 KiB
Markdown
572 lines
24 KiB
Markdown
# 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
|