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 */}
+
+
+ {/* 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 */}
+
+
+ {/* 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}