diff --git a/app/components/ui/background-boxes.tsx b/app/components/ui/background-boxes.tsx new file mode 100644 index 0000000..d0f4259 --- /dev/null +++ b/app/components/ui/background-boxes.tsx @@ -0,0 +1,76 @@ +"use client"; +import React from "react"; +import { motion } from "motion/react"; +import { cn } from "~/lib/utils"; + +export const BoxesCore = ({ className, ...rest }: { className?: string }) => { + const rows = new Array(150).fill(1); + const cols = new Array(100).fill(1); + let colors = [ + "#93c5fd", + "#f9a8d4", + "#86efac", + "#fde047", + "#fca5a5", + "#d8b4fe", + "#93c5fd", + "#a5b4fc", + "#c4b5fd" + ]; + const getRandomColor = () => { + return colors[Math.floor(Math.random() * colors.length)]; + }; + + return ( +
+ {rows.map((_, i) => ( + + {cols.map((_, j) => ( + + {j % 2 === 0 && i % 2 === 0 ? ( + + + + ) : null} + + ))} + + ))} +
+ ); +}; + +export const Boxes = React.memo(BoxesCore); diff --git a/app/components/ui/floatingdocks.tsx b/app/components/ui/floatingdocks.tsx new file mode 100644 index 0000000..e32ec1f --- /dev/null +++ b/app/components/ui/floatingdocks.tsx @@ -0,0 +1,236 @@ +import { cn } from "~/lib/utils"; +import { Menu } from "lucide-react"; +import { + AnimatePresence, + MotionValue, + motion, + useMotionValue, + useSpring, + useTransform +} from "motion/react"; +import { useRef, useState } from "react"; + +export const FloatingDock = ({ + items, + desktopClassName, + mobileClassName +}: { + items: { + title: string; + icon: React.ReactNode; + href: string; + isActive?: boolean; + onClick?: () => void; + }[]; + desktopClassName?: string; + mobileClassName?: string; +}) => { + return ( + <> + + + + ); +}; + +const FloatingDockMobile = ({ + items, + className +}: { + items: { + title: string; + icon: React.ReactNode; + href: string; + isActive?: boolean; + onClick?: () => void; + }[]; + className?: string; +}) => { + const [open, setOpen] = useState(false); + return ( +
+ + {open && ( + + {items.map((item, idx) => ( + + + + ))} + + )} + + +
+ ); +}; + +const FloatingDockDesktop = ({ + items, + className +}: { + items: { + title: string; + icon: React.ReactNode; + href: string; + isActive?: boolean; + onClick?: () => void; + }[]; + className?: string; +}) => { + let mouseX = useMotionValue(Infinity); + return ( + mouseX.set(e.pageX)} + onMouseLeave={() => mouseX.set(Infinity)} + className={cn( + "mx-auto hidden h-16 items-end gap-4 rounded-2xl bg-gray-50/80 backdrop-blur-sm px-4 pb-3 md:flex dark:bg-neutral-900/80 border border-gray-200/50 dark:border-neutral-800/50 shadow-lg", + className + )} + > + {items.map((item) => ( + + ))} + + ); +}; + +function IconContainer({ + mouseX, + title, + icon, + href, + isActive, + onClick +}: { + mouseX: MotionValue; + title: string; + icon: React.ReactNode; + href: string; + isActive?: boolean; + onClick?: () => void; +}) { + let ref = useRef(null); + + let distance = useTransform(mouseX, (val) => { + let bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 }; + return val - bounds.x - bounds.width / 2; + }); + + let widthTransform = useTransform(distance, [-150, 0, 150], [40, 80, 40]); + let heightTransform = useTransform(distance, [-150, 0, 150], [40, 80, 40]); + + let widthTransformIcon = useTransform(distance, [-150, 0, 150], [20, 40, 20]); + let heightTransformIcon = useTransform( + distance, + [-150, 0, 150], + [20, 40, 20] + ); + + let width = useSpring(widthTransform, { + mass: 0.1, + stiffness: 150, + damping: 12 + }); + let height = useSpring(heightTransform, { + mass: 0.1, + stiffness: 150, + damping: 12 + }); + + let widthIcon = useSpring(widthTransformIcon, { + mass: 0.1, + stiffness: 150, + damping: 12 + }); + let heightIcon = useSpring(heightTransformIcon, { + mass: 0.1, + stiffness: 150, + damping: 12 + }); + + const [hovered, setHovered] = useState(false); + + return ( + + ); +} diff --git a/app/components/ui/floatingthemeswitch.tsx b/app/components/ui/floatingthemeswitch.tsx new file mode 100644 index 0000000..9b49ca1 --- /dev/null +++ b/app/components/ui/floatingthemeswitch.tsx @@ -0,0 +1,52 @@ +import { Theme, useTheme } from "remix-themes"; +import { FloatingDock } from "./floatingdocks"; +import { Sun, Moon } from "lucide-react"; + +interface ThemeFloatingDockProps { + className?: string; +} + +export function ThemeFloatingDock({ className }: ThemeFloatingDockProps) { + const [theme, setTheme] = useTheme(); + + const items = [ + { + title: "Light Mode", + icon: ( + + ), + href: "#", + isActive: theme === Theme.LIGHT, + onClick: () => setTheme(Theme.LIGHT) + }, + { + title: "Dark Mode", + icon: ( + + ), + href: "#", + isActive: theme === Theme.DARK, + onClick: () => setTheme(Theme.DARK) + } + ]; + + return ( + + ); +} diff --git a/app/routes/sys-rijig-administator.emailotpverifyrequired.tsx b/app/routes/sys-rijig-administator.emailotpverifyrequired.tsx new file mode 100644 index 0000000..b54e698 --- /dev/null +++ b/app/routes/sys-rijig-administator.emailotpverifyrequired.tsx @@ -0,0 +1,353 @@ +import { + json, + redirect, + type ActionFunctionArgs, + type LoaderFunctionArgs +} from "@remix-run/node"; +import { + Form, + useActionData, + useLoaderData, + useNavigation, + useSearchParams +} from "@remix-run/react"; +import { useState, useEffect, useRef } from "react"; +import { cn } from "~/lib/utils"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; +import { Alert, AlertDescription } from "~/components/ui/alert"; +import { + Shield, + Mail, + Clock, + CheckCircle, + AlertCircle, + ArrowLeft, + RefreshCw, + Loader2 +} from "lucide-react"; + +interface LoaderData { + email: string; + otpSentAt: string; + expiryMinutes: number; +} + +interface OTPActionData { + success: boolean; + message?: string; + otpSentAt?: string; + errors?: { + otp?: string; + general?: string; + }; +} + +export const loader = async ({ + request +}: LoaderFunctionArgs): Promise => { + const url = new URL(request.url); + const email = url.searchParams.get("email"); + if (!email) { + return redirect("/sys-rijig-administrator/sign-infirst"); + } + + return json({ + email, + otpSentAt: new Date().toISOString(), + expiryMinutes: 5 + }); +}; + +export const action = async ({ + request +}: ActionFunctionArgs): Promise => { + const formData = await request.formData(); + const otp = formData.get("otp") as string; + const email = formData.get("email") as string; + const action = formData.get("_action") as string; + + if (action === "resend") { + console.log("Resending OTP to:", email); + + return json({ + success: true, + message: "Kode OTP baru telah dikirim ke email Anda", + otpSentAt: new Date().toISOString() + }); + } + + if (action === "verify") { + const errors: { otp?: string; general?: string } = {}; + + if (!otp || otp.length !== 4) { + errors.otp = "Kode OTP harus 4 digit"; + } else if (!/^\d{4}$/.test(otp)) { + errors.otp = "Kode OTP hanya boleh berisi angka"; + } + + if (Object.keys(errors).length > 0) { + return json({ errors, success: false }, { status: 400 }); + } + + if (otp === "1234") { + return redirect("/sys-rijig-adminpanel/dashboard"); + } + + return json( + { + errors: { otp: "Kode OTP tidak valid atau sudah kedaluwarsa" }, + success: false + }, + { status: 401 } + ); + } + + return json( + { + errors: { general: "Aksi tidak valid" }, + success: false + }, + { status: 400 } + ); +}; + +export default function AdminVerifyOTP() { + const { email, otpSentAt, expiryMinutes } = useLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const [searchParams] = useSearchParams(); + + const [otp, setOtp] = useState(["", "", "", ""]); + const [timeLeft, setTimeLeft] = useState(expiryMinutes * 60); + const [canResend, setCanResend] = useState(false); + + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + const isSubmitting = navigation.state === "submitting"; + const isResending = navigation.formData?.get("_action") === "resend"; + const isVerifying = navigation.formData?.get("_action") === "verify"; + + useEffect(() => { + const timer = setInterval(() => { + setTimeLeft((prev) => { + if (prev <= 1) { + setCanResend(true); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, []); + + useEffect(() => { + if (actionData?.success && actionData?.otpSentAt) { + setTimeLeft(expiryMinutes * 60); + setCanResend(false); + } + }, [actionData, expiryMinutes]); + + const handleOtpChange = (index: number, value: string) => { + if (!/^\d*$/.test(value)) return; + + const newOtp = [...otp]; + newOtp[index] = value; + setOtp(newOtp); + + if (value && index < 3) { + inputRefs.current[index + 1]?.focus(); + } + }; + + const handleKeyDown = (index: number, e: React.KeyboardEvent) => { + if (e.key === "Backspace" && !otp[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pastedText = e.clipboardData.getData("text"); + const digits = pastedText.replace(/\D/g, "").slice(0, 4); + + if (digits.length === 4) { + const newOtp = digits.split(""); + setOtp(newOtp); + inputRefs.current[3]?.focus(); + } + }; + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, "0")}`; + }; + + const maskedEmail = email.replace(/(.{2})(.*)(@.*)/, "$1***$3"); + + return ( +
+
+ + +
+ +
+ + Verifikasi Email + +

+ Masukkan kode OTP 4 digit yang telah dikirim ke +

+

{maskedEmail}

+
+ + + {/* Success Alert */} + {actionData?.success && actionData?.message && ( + + + + {actionData.message} + + + )} + + {/* Error Alert */} + {actionData?.errors?.otp && ( + + + {actionData.errors.otp} + + )} + + {/* OTP Input Form */} +
+ + + + +
+ {/* OTP Input Fields */} +
+ {otp.map((digit, index) => ( + (inputRefs.current[index] = el)} + type="text" + maxLength={1} + value={digit} + onChange={(e) => handleOtpChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + onPaste={handlePaste} + className={cn( + "w-12 h-12 text-center text-lg font-bold", + actionData?.errors?.otp && "border-red-500" + )} + autoFocus={index === 0} + /> + ))} +
+ + {/* Timer */} +
+ {timeLeft > 0 ? ( +
+ + Kode kedaluwarsa dalam {formatTime(timeLeft)} +
+ ) : ( +
+ Kode OTP telah kedaluwarsa +
+ )} +
+ + {/* Verify Button */} + +
+
+ + {/* Resend OTP */} +
+

Tidak menerima kode?

+
+ + + +
+
+ + {/* Back to Login */} + + + {/* Demo Info */} +
+

+ Demo OTP: +

+
+

+ Gunakan kode:{" "} + 1234 +

+

Atau tunggu countdown habis untuk test resend

+
+
+
+
+ + {/* Footer */} +
+

+ Sistem Pengelolaan Sampah Terpadu +

+
+
+
+ ); +} diff --git a/app/routes/sys-rijig-administrator.sign-infirst.tsx b/app/routes/sys-rijig-administrator.sign-infirst.tsx new file mode 100644 index 0000000..e7e928f --- /dev/null +++ b/app/routes/sys-rijig-administrator.sign-infirst.tsx @@ -0,0 +1,297 @@ +import { + json, + redirect, + type ActionFunctionArgs, + type LoaderFunctionArgs +} from "@remix-run/node"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; +import { useState, useEffect } from "react"; +import { cn } from "~/lib/utils"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent } from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { Alert, AlertDescription } from "~/components/ui/alert"; +import { + Recycle, + Mail, + Lock, + Eye, + EyeOff, + Shield, + AlertCircle, + Loader2 +} from "lucide-react"; +import { Boxes } from "~/components/ui/background-boxes"; +import { ThemeFloatingDock } from "~/components/ui/floatingthemeswitch"; + +// Interface untuk action response +interface LoginActionData { + errors?: { + email?: string; + password?: string; + general?: string; + }; + success: boolean; +} + +// Loader - cek apakah user sudah login +export const loader = async ({ request }: LoaderFunctionArgs) => { + // Dalam implementasi nyata, cek session/cookie + // const session = await getSession(request.headers.get("Cookie")); + // if (session.has("adminId")) { + // return redirect("/admin/dashboard"); + // } + + return json({}); +}; + +// Action untuk handle login +export const action = async ({ + request +}: ActionFunctionArgs): Promise => { + const formData = await request.formData(); + const email = formData.get("email") as string; + const password = formData.get("password") as string; + const remember = formData.get("remember") === "on"; + + // Validation + const errors: { email?: string; password?: string; general?: string } = {}; + + if (!email) { + errors.email = "Email wajib diisi"; + } else if (!/\S+@\S+\.\S+/.test(email)) { + errors.email = "Format email tidak valid"; + } + + if (!password) { + errors.password = "Password wajib diisi"; + } else if (password.length < 6) { + errors.password = "Password minimal 6 karakter"; + } + + if (Object.keys(errors).length > 0) { + return json({ errors, success: false }, { status: 400 }); + } + + // Simulasi autentikasi - dalam implementasi nyata, cek ke database + if (email === "admin@wastemanagement.com" && password === "admin123") { + // Set session dan redirect + // const session = await getSession(request.headers.get("Cookie")); + // session.set("adminId", "admin-001"); + // session.set("adminName", "Administrator"); + // session.set("adminEmail", email); + + // Redirect ke OTP verification + return redirect( + `/sys-rijig-administator/emailotpverifyrequired?email=${encodeURIComponent( + email + )}` + ); + } + + return json( + { + errors: { general: "Email atau password salah" }, + success: false + }, + { status: 401 } + ); +}; + +export default function AdminLogin() { + const actionData = useActionData(); + const navigation = useNavigation(); + const [showPassword, setShowPassword] = useState(false); + + const isSubmitting = navigation.state === "submitting"; + + return ( +
+
+ + {/*
*/} +
+
+ + + {/* Form Login */} +
+
+ {/* Header */} +
+
+ +
+

+ Portal Administrator +

+

+ Sistem Pengelolaan Sampah Terpadu +

+
+ + {/* Error Alert */} + {actionData?.errors?.general && ( + + + + {actionData.errors.general} + + + )} + + {/* Email Field */} +
+ +
+ + +
+ {actionData?.errors?.email && ( +

+ {actionData.errors.email} +

+ )} +
+ + {/* Password Field */} +
+
+ + + Lupa password? + +
+
+ + + +
+ {actionData?.errors?.password && ( +

+ {actionData.errors.password} +

+ )} +
+ + {/* Remember Me */} +
+ + +
+ + {/* Submit Button */} + + + {/* Demo Credentials */} +
+

+ Demo Credentials: +

+
+

Email: admin@wastemanagement.com

+

Password: admin123

+
+
+
+
+ + {/* Side Image */} +
+
+
+
+ +
+

+ Kelola Sistem Sampah +

+

+ Platform terpadu untuk mengelola pengumpulan, pengolahan, + dan monitoring sampah di seluruh wilayah dengan efisiensi + maksimal. +

+ + {/* Features List */} +
+
+
+ Monitoring Real-time +
+
+
+ Manajemen Armada +
+
+
+ Laporan Analytics +
+
+
+ Koordinasi Tim +
+
+
+
+
+
+
+ +
+
+ ); +}