fe6e96dd3f
Shadboard starter-kit (Next.js 15 + React 19 + shadcn/ui + Tailwind 4) Sprint 4.a — Admin Dashboard: - Auth: NextAuth.js v5, login page, middleware, token rotation - Dashboard: KPI cards, Recharts stock chart, quick actions - Members: TanStack Table (search/sort/paginate), add/edit forms - Distributions: multi-step form, real-time quota check, history - Stock: batch management, recall dialog, bar chart - Reports: monthly/member-list/recall, PDF/CSV download, preview Sprint 4.b — Member Portal: - Separate route group with top-nav layout (mobile-first) - Quota dashboard with radial SVG progress indicators - Distribution history with month filter - Profile/settings with password change Cross-cutting: - i18n: German (default) + English via next-intl - Dark + light mode (next-themes, user-togglable) - Playwright E2E tests (6/6 green) - Docker multi-stage build (node:22-alpine) - API proxy via Next.js rewrites Tech: Next.js 15.2.8, React 19, Tailwind 4, NextAuth v5, TanStack Table, Recharts, Zod, React Hook Form, Playwright
145 lines
5.2 KiB
TypeScript
145 lines
5.2 KiB
TypeScript
"use client"
|
|
|
|
import { useState } from "react"
|
|
import Link from "next/link"
|
|
import { useRouter } from "next/navigation"
|
|
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"
|
|
|
|
const loginSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(1),
|
|
})
|
|
|
|
type LoginFormData = z.infer<typeof loginSchema>
|
|
|
|
export default function PortalLoginPage() {
|
|
const t = useTranslations("portal")
|
|
const router = useRouter()
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
formState: { errors, isSubmitting },
|
|
} = useForm<LoginFormData>({
|
|
resolver: zodResolver(loginSchema),
|
|
})
|
|
|
|
async function onSubmit(_data: LoginFormData) {
|
|
setError(null)
|
|
|
|
try {
|
|
// Mock login — just redirect to portal dashboard
|
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
router.push("/portal/dashboard")
|
|
} catch {
|
|
setError(t("networkError"))
|
|
}
|
|
}
|
|
|
|
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>
|
|
<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}
|
|
</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-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>
|
|
|
|
{/* 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>
|
|
)
|
|
}
|