- 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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user