feat: logout session

This commit is contained in:
pahmiudahgede 2025-07-09 07:33:19 +07:00
parent 83942347f5
commit fd740a99c3
8 changed files with 1521 additions and 1123 deletions

View File

@ -1,5 +1,5 @@
import { useState } from "react";
import { Form } from "@remix-run/react";
import { Form, useNavigation } from "@remix-run/react";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
@ -13,6 +13,16 @@ import {
DropdownMenuTrigger
} from "~/components/ui/dropdown-menu";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from "~/components/ui/alert-dialog";
import {
Menu,
Search,
@ -25,7 +35,8 @@ import {
ChevronDown,
MoreHorizontal,
PanelLeftClose,
PanelLeft
PanelLeft,
Loader2
} from "lucide-react";
import { SessionData } from "~/sessions.server"; // Import SessionData type
@ -42,7 +53,9 @@ export function PengelolaHeader({
isMobile,
user // Add user prop
}: PengelolaHeaderProps) {
// const [isDark, setIsDark] = useState(false);
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const navigation = useNavigation();
const isLoggingOut = navigation.formAction === "/action/logout";
// Get user initials for avatar fallback
const getUserInitials = (name: string) => {
@ -55,204 +68,214 @@ export function PengelolaHeader({
};
return (
<header className="sticky top-0 flex w-full bg-white border-b border-gray-200 z-40 dark:border-gray-700 dark:bg-gray-800">
<div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
{/* Mobile header */}
<div className="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark: sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4">
{/* Hamburger menu button */}
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="hover:bg-gray-100 dark:hover:bg-gray-700 border-gray-300 dark:border-gray-600 dark:bg-gray-800"
>
{isMobile ? (
<Menu className="h-5 w-5" />
) : sidebarCollapsed ? (
<PanelLeft className="h-5 w-5" />
) : (
<PanelLeftClose className="h-5 w-5" />
)}
</Button>
<>
<header className="sticky top-0 flex w-full bg-white border-b border-gray-200 z-40 dark:border-gray-700 dark:bg-gray-800">
<div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
{/* Mobile header */}
<div className="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark: sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4">
{/* Hamburger menu button */}
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="hover:bg-gray-100 dark:hover:bg-gray-700 border-gray-300 dark:border-gray-600 dark:bg-gray-800"
>
{isMobile ? (
<Menu className="h-5 w-5" />
) : sidebarCollapsed ? (
<PanelLeft className="h-5 w-5" />
) : (
<PanelLeftClose className="h-5 w-5" />
)}
</Button>
{/* Mobile logo */}
<div className="lg:hidden">
<div className="w-24 h-6 bg-gray-800 dark:bg-white rounded flex items-center justify-center text-white dark:text-gray-900 text-xs font-bold">
LOGO
</div>
</div>
{/* Mobile more menu */}
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden">
<MoreHorizontal className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-80">
<div className="mt-6 space-y-4">
<Button className="w-full" variant="outline">
<Bell className="mr-2 h-4 w-4" />
Notifications
<Badge variant="destructive" className="ml-auto">
3
</Badge>
</Button>
<ModeToggle />
</div>
</SheetContent>
</Sheet>
{/* Desktop search - Commented out for now */}
{/* <div className="hidden lg:block">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500 dark:text-gray-400" />
<Input
type="text"
placeholder="Search or type command..."
className="w-96 pl-10 pr-14 bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<kbd className="inline-flex items-center gap-1 rounded border border-gray-200 bg-white px-1.5 py-0.5 text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
K
</kbd>
{/* Mobile logo */}
<div className="lg:hidden">
<div className="w-24 h-6 bg-gray-800 dark:bg-white rounded flex items-center justify-center text-white dark:text-gray-900 text-xs font-bold">
LOGO
</div>
</div>
</div> */}
</div>
{/* Desktop header actions */}
<div className="hidden items-center justify-between w-full gap-4 px-5 py-4 lg:flex lg:justify-end lg:px-0">
<div className="flex items-center gap-3">
{/* Theme toggle */}
<ModeToggle />
{/* Notifications */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative hover:bg-gray-100 dark:hover:bg-gray-700 border-gray-300 dark:border-gray-600 dark:bg-gray-800"
>
<Bell className="h-4 w-4" />
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 text-xs flex items-center justify-center animate-pulse"
>
3
</Badge>
{/* Mobile more menu */}
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden">
<MoreHorizontal className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-80 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
>
<div className="p-4">
<h4 className="font-semibold mb-2 flex items-center justify-between text-gray-900 dark:text-gray-100">
</SheetTrigger>
<SheetContent side="right" className="w-80">
<div className="mt-6 space-y-4">
<Button className="w-full" variant="outline">
<Bell className="mr-2 h-4 w-4" />
Notifications
<Badge variant="secondary" className="text-xs">
3 new
<Badge variant="destructive" className="ml-auto">
3
</Badge>
</h4>
<div className="space-y-2">
<div className="text-sm text-gray-600 dark:text-gray-200 p-3 rounded-lg bg-gray-50 dark:bg-gray-700 border-l-4 border-blue-500">
<p className="font-medium text-gray-900 dark:text-gray-100">
New user registered
</p>
<p className="text-xs text-gray-500 dark:text-gray-300 mt-1">
2 minutes ago
</p>
</div>
<div className="text-sm text-gray-600 dark:text-gray-200 p-3 rounded-lg bg-gray-50 dark:bg-gray-700 border-l-4 border-green-500">
<p className="font-medium text-gray-900 dark:text-gray-100">
System update available
</p>
<p className="text-xs text-gray-500 dark:text-gray-300 mt-1">
1 hour ago
</p>
</div>
<div className="text-sm text-gray-600 dark:text-gray-200 p-3 rounded-lg bg-gray-50 dark:bg-gray-700 border-l-4 border-orange-500">
<p className="font-medium text-gray-900 dark:text-gray-100">
New message received
</p>
<p className="text-xs text-gray-500 dark:text-gray-300 mt-1">
3 hours ago
</p>
</div>
</div>
<Button variant="outline" className="w-full mt-3 text-xs">
View all notifications
</Button>
<ModeToggle />
</div>
</DropdownMenuContent>
</DropdownMenu>
</SheetContent>
</Sheet>
</div>
{/* User menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-auto p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
{/* Desktop header actions */}
<div className="hidden items-center justify-between w-full gap-4 px-5 py-4 lg:flex lg:justify-end lg:px-0">
<div className="flex items-center gap-3">
{/* Theme toggle */}
<ModeToggle />
{/* Notifications */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative hover:bg-gray-100 dark:hover:bg-gray-700 border-gray-300 dark:border-gray-600 dark:bg-gray-800"
>
<Bell className="h-4 w-4" />
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 text-xs flex items-center justify-center animate-pulse"
>
3
</Badge>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-80 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage
src={""}
alt={"User"}
/>
<AvatarFallback className="bg-blue-600 text-white">
{getUserInitials("User")}
</AvatarFallback>
</Avatar>
<div className="hidden sm:block text-left">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{user.sessionId || "User"}
<div className="p-4">
<h4 className="font-semibold mb-2 flex items-center justify-between text-gray-900 dark:text-gray-100">
Notifications
<Badge variant="secondary" className="text-xs">
3 new
</Badge>
</h4>
<div className="space-y-2">
<div className="text-sm text-gray-600 dark:text-gray-200 p-3 rounded-lg bg-gray-50 dark:bg-gray-700 border-l-4 border-blue-500">
<p className="font-medium text-gray-900 dark:text-gray-100">
New user registered
</p>
<p className="text-xs text-gray-500 dark:text-gray-300 mt-1">
2 minutes ago
</p>
</div>
<div className="text-xs text-gray-500 dark:text-gray-300">
{user.role || "Pengelola"}
<div className="text-sm text-gray-600 dark:text-gray-200 p-3 rounded-lg bg-gray-50 dark:bg-gray-700 border-l-4 border-green-500">
<p className="font-medium text-gray-900 dark:text-gray-100">
System update available
</p>
<p className="text-xs text-gray-500 dark:text-gray-300 mt-1">
1 hour ago
</p>
</div>
<div className="text-sm text-gray-600 dark:text-gray-200 p-3 rounded-lg bg-gray-50 dark:bg-gray-700 border-l-4 border-orange-500">
<p className="font-medium text-gray-900 dark:text-gray-100">
New message received
</p>
<p className="text-xs text-gray-500 dark:text-gray-300 mt-1">
3 hours ago
</p>
</div>
</div>
<ChevronDown className="h-4 w-4 text-gray-500" />
<Button variant="outline" className="w-full mt-3 text-xs">
View all notifications
</Button>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-56 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
>
<div className="px-2 py-1.5 text-sm text-gray-700 dark:text-gray-200">
<div className="font-medium">{user.phone || "User"}</div>
<div className="text-xs text-gray-500 dark:text-gray-300">
{user.email || "user@example.com"}
</DropdownMenuContent>
</DropdownMenu>
{/* User menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-auto p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={""} alt={"User"} />
<AvatarFallback className="bg-blue-600 text-white">
{getUserInitials("User")}
</AvatarFallback>
</Avatar>
<div className="hidden sm:block text-left">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{user.phone || "User"}
</div>
<div className="text-xs text-gray-500 dark:text-gray-300">
{user.role || "Pengelola"}
</div>
</div>
<ChevronDown className="h-4 w-4 text-gray-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-56 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
>
<div className="px-2 py-1.5 text-sm text-gray-700 dark:text-gray-200">
<div className="font-medium">{user.phone || "User"}</div>
<div className="text-xs text-gray-500 dark:text-gray-300">
{user.email || "user@example.com"}
</div>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">
<User className="mr-2 h-4 w-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Form action="/logout" method="post" className="w-full">
<button
type="submit"
className="flex w-full items-center text-red-600 dark:text-red-400 cursor-pointer"
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</button>
</Form>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">
<User className="mr-2 h-4 w-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-red-600 dark:text-red-400"
onClick={() => setShowLogoutDialog(true)}
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
</header>
</header>
{/* Logout Confirmation Dialog */}
<AlertDialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Konfirmasi Logout</AlertDialogTitle>
<AlertDialogDescription>
Apakah Anda yakin ingin keluar dari akun? Anda perlu login kembali
untuk mengakses dashboard.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoggingOut}>Batal</AlertDialogCancel>
<Form method="post" action="/action/logout">
<AlertDialogAction
type="submit"
className="bg-red-600 hover:bg-red-700"
disabled={isLoggingOut}
>
{isLoggingOut ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Logging out...
</>
) : (
"Ya, Logout"
)}
</AlertDialogAction>
</Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -12,9 +12,12 @@ import {
Linkedin,
Instagram,
Recycle,
ArrowUp
ArrowUp,
ArrowRight,
User
} from "lucide-react";
import { ModeToggle } from "~/components/ui/dark-mode-toggle";
import { getUserSession } from "~/sessions.server";
export const meta: MetaFunction = () => {
return [
@ -44,10 +47,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
{ name: "Instagram", icon: "instagram", href: "#" }
];
// Get real user session
const userSession = await getUserSession(request);
const authData = {
isAuthenticated: false,
isRegistrationComplete: false,
userRole: null as string | null
isAuthenticated: !!userSession,
isRegistrationComplete: userSession?.registrationStatus === "complete",
userRole: userSession?.role || null,
userPhone: userSession?.phone || null,
userEmail: userSession?.email || null
};
return json({
@ -65,7 +73,13 @@ export default function LandingLayout() {
const [isScrolled, setIsScrolled] = useState(false);
const [showBackToTop, setShowBackToTop] = useState(false);
const { isAuthenticated, isRegistrationComplete, userRole } = authData;
const {
isAuthenticated,
isRegistrationComplete,
userRole,
userPhone,
userEmail
} = authData;
useEffect(() => {
const handleScroll = () => {
@ -100,39 +114,58 @@ export default function LandingLayout() {
}
};
const getRedirectPath = () => {
const getDashboardPath = () => {
if (userRole === "administrator") {
return "/sys-rijig-adminpanel/dashboard";
}
return "/pengelola/dashboard";
};
const handleGetStarted = () => {
const handleMainAction = () => {
if (isAuthenticated && isRegistrationComplete) {
const dashboardPath =
userRole === "administrator"
? "/sys-rijig-adminpanel/dashboard"
: "/pengelola/dashboard";
navigate(dashboardPath);
// User sudah login dan registrasi complete → Go to Dashboard
navigate(getDashboardPath());
} else if (isAuthenticated && !isRegistrationComplete) {
const redirectPath = getRedirectPath();
navigate(redirectPath);
// User sudah login tapi registrasi belum complete → Redirect ke step berikutnya
navigate(getDashboardPath());
} else {
navigate("/pengelola/register");
// User belum login → Get Started (redirect ke authpengelola)
navigate("/authpengelola");
}
};
const handleAdminLogin = () => {
navigate("/sys-rijig-adminpanel/login");
};
const getButtonText = () => {
if (isAuthenticated && isRegistrationComplete) {
return "Go to Dashboard";
} else if (isAuthenticated && !isRegistrationComplete) {
return "Continue Setup";
}
return "Get Started";
};
const getButtonIcon = () => {
if (isAuthenticated) {
return <User className="ml-2 h-4 w-4" />;
}
return <ArrowRight className="ml-2 h-4 w-4" />;
};
// Format phone display
const formatPhone = (phone: string) => {
if (phone.length <= 2) return phone;
if (phone.length <= 5)
return `${phone.substring(0, 2)} ${phone.substring(2)}`;
if (phone.length <= 9)
return `${phone.substring(0, 2)} ${phone.substring(
2,
5
)} ${phone.substring(5)}`;
return `${phone.substring(0, 2)} ${phone.substring(2, 5)} ${phone.substring(
5,
9
)} ${phone.substring(9)}`;
};
return (
<div className="min-h-screen">
{/* Header dengan glassmorphism effect */}
@ -213,6 +246,26 @@ export default function LandingLayout() {
{/* Right Side Actions */}
<div className="hidden lg:flex items-center space-x-4">
{/* User Info (if authenticated) */}
{isAuthenticated && (
<div
className={`text-sm transition-all duration-700 ${
isScrolled
? "text-gray-600 dark:text-gray-300"
: "text-white/80 dark:text-white/80"
}`}
>
<div className="text-right">
<div className="font-medium">
{userRole === "administrator" ? "Admin" : "Pengelola"}
</div>
<div className="text-xs">
{userPhone ? formatPhone(userPhone) : userEmail}
</div>
</div>
</div>
)}
<div
className={`transition-all duration-700 ${
isScrolled
@ -222,8 +275,9 @@ export default function LandingLayout() {
>
<ModeToggle />
</div>
<Button
onClick={handleGetStarted}
onClick={handleMainAction}
className={`transition-all duration-700 shadow-lg hover:scale-105 rounded-lg ${
isScrolled
? "bg-green-600 hover:bg-green-700 text-white shadow-green-600/20"
@ -231,6 +285,7 @@ export default function LandingLayout() {
}`}
>
{getButtonText()}
{getButtonIcon()}
</Button>
</div>
</div>
@ -251,6 +306,35 @@ export default function LandingLayout() {
}`}
>
<nav className="space-y-4 p-4">
{/* User Info Mobile (if authenticated) */}
{isAuthenticated && (
<>
<div
className={`text-center text-sm transition-all duration-700 ${
isScrolled
? "text-gray-600 dark:text-gray-300"
: "text-white/80 dark:text-white/80"
}`}
>
<div className="font-medium">
{userRole === "administrator"
? "Administrator"
: "Pengelola"}
</div>
<div className="text-xs">
{userPhone ? formatPhone(userPhone) : userEmail}
</div>
</div>
<Separator
className={`my-4 ${
isScrolled
? "bg-gray-200/60 dark:bg-gray-700/60"
: "bg-white/40 dark:bg-gray-700/40"
}`}
/>
</>
)}
{navigationItems.map((item) => (
<button
key={item.href}
@ -285,7 +369,7 @@ export default function LandingLayout() {
<ModeToggle />
</div>
<Button
onClick={handleGetStarted}
onClick={handleMainAction}
size="sm"
className={`hover:scale-105 transition-all duration-700 rounded-lg ${
isScrolled
@ -294,6 +378,7 @@ export default function LandingLayout() {
}`}
>
{getButtonText()}
{getButtonIcon()}
</Button>
</div>
</nav>
@ -361,14 +446,6 @@ export default function LandingLayout() {
>
<Facebook className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-white hover:text-white/80 hover:bg-green-700 transition-all hover:scale-110 rounded-lg"
aria-label="Twitter"
>
<Twitter className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"

View File

@ -0,0 +1,26 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { destroySession, getSession } from "~/sessions.server";
import commonAuthService from "~/services/auth/common.service";
export const action = async ({ request }: ActionFunctionArgs) => {
const session = await getSession(request);
try {
await commonAuthService.logout();
} catch (error) {
console.error("Logout API error:", error);
}
commonAuthService.removeAuthToken();
return redirect("/", {
headers: {
"Set-Cookie": await destroySession(session)
}
});
};
export const loader = async () => {
throw new Response("Not Found", { status: 404 });
};

View File

@ -35,8 +35,13 @@ import {
MapPin,
User,
FileText,
Phone
Phone,
Upload,
Image as ImageIcon
} from "lucide-react";
import { formatDateToDDMMYYYY, validatePhoneNumber } from "~/utils/auth-utils";
import pengelolaAuthService from "~/services/auth/pengelola.service";
import { getUserSession, createUserSession } from "~/sessions.server";
// Progress Indicator Component
const ProgressIndicator = ({ currentStep = 3, totalSteps = 5 }) => {
@ -79,20 +84,20 @@ const ProgressIndicator = ({ currentStep = 3, totalSteps = 5 }) => {
// Interfaces
interface LoaderData {
phone: string;
userSession: any;
}
interface CompanyProfileActionData {
errors?: {
companyName?: string;
ownerName?: string;
companyType?: string;
address?: string;
city?: string;
postalCode?: string;
businessType?: string;
employeeCount?: string;
serviceArea?: string;
companyname?: string;
companyaddress?: string;
companyphone?: string;
companyemail?: string;
companywebsite?: string;
taxid?: string;
foundeddate?: string;
companytype?: string;
companydescription?: string;
general?: string;
};
success?: boolean;
@ -101,98 +106,177 @@ interface CompanyProfileActionData {
export const loader = async ({
request
}: LoaderFunctionArgs): Promise<Response> => {
const url = new URL(request.url);
const phone = url.searchParams.get("phone");
const userSession = await getUserSession(request);
if (!phone) {
return redirect("/authpengelola/requestotpforregister");
// Check if user is authenticated and has pengelola role
if (!userSession || userSession.role !== "pengelola") {
return redirect("/authpengelola");
}
return json<LoaderData>({ phone });
// Check if user should be on this step
if (userSession.registrationStatus !== "uncomplete") {
// Redirect based on current status
switch (userSession.registrationStatus) {
case "awaiting_approval":
return redirect("/authpengelola/waitingapprovalfromadministrator");
case "approved":
return redirect("/authpengelola/createanewpin");
case "complete":
return redirect("/pengelola/dashboard");
default:
break;
}
}
return json<LoaderData>({ userSession });
};
export const action = async ({
request
}: ActionFunctionArgs): Promise<Response> => {
const formData = await request.formData();
const phone = formData.get("phone") as string;
const userSession = await getUserSession(request);
// Extract form data
if (!userSession || userSession.role !== "pengelola") {
return redirect("/authpengelola");
}
const formData = await request.formData();
// Extract form data sesuai dengan API requirements
const companyData = {
companyName: formData.get("companyName") as string,
ownerName: formData.get("ownerName") as string,
companyType: formData.get("companyType") as string,
address: formData.get("address") as string,
city: formData.get("city") as string,
postalCode: formData.get("postalCode") as string,
businessType: formData.get("businessType") as string,
employeeCount: formData.get("employeeCount") as string,
serviceArea: formData.get("serviceArea") as string,
description: formData.get("description") as string
companyname: formData.get("companyname") as string,
companyaddress: formData.get("companyaddress") as string,
companyphone: formData.get("companyphone") as string,
companyemail: formData.get("companyemail") as string,
companywebsite: formData.get("companywebsite") as string,
taxid: formData.get("taxid") as string,
foundeddate: formData.get("foundeddate") as string,
companytype: formData.get("companytype") as string,
companydescription: formData.get("companydescription") as string,
company_logo: formData.get("company_logo") as File | null
};
// Validation
const errors: { [key: string]: string } = {};
if (!companyData.companyName?.trim()) {
errors.companyName = "Nama perusahaan wajib diisi";
if (!companyData.companyname?.trim()) {
errors.companyname = "Nama perusahaan wajib diisi";
}
if (!companyData.ownerName?.trim()) {
errors.ownerName = "Nama pemilik/direktur wajib diisi";
if (!companyData.companyaddress?.trim()) {
errors.companyaddress = "Alamat perusahaan wajib diisi";
}
if (!companyData.companyType) {
errors.companyType = "Jenis badan usaha wajib dipilih";
if (!companyData.companyphone?.trim()) {
errors.companyphone = "Nomor telepon perusahaan wajib diisi";
} else if (!validatePhoneNumber(companyData.companyphone)) {
errors.companyphone =
"Format nomor telepon tidak valid (gunakan format 628xxxxxxxxx)";
}
if (!companyData.address?.trim()) {
errors.address = "Alamat lengkap wajib diisi";
if (!companyData.companyemail?.trim()) {
errors.companyemail = "Email perusahaan wajib diisi";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(companyData.companyemail)) {
errors.companyemail = "Format email tidak valid";
}
if (!companyData.city?.trim()) {
errors.city = "Kota wajib diisi";
if (!companyData.companywebsite?.trim()) {
errors.companywebsite = "Website perusahaan wajib diisi";
} else if (!/^https?:\/\/.+\..+/.test(companyData.companywebsite)) {
errors.companywebsite =
"Format website tidak valid (harus dimulai dengan http:// atau https://)";
}
if (!companyData.postalCode?.trim()) {
errors.postalCode = "Kode pos wajib diisi";
} else if (!/^\d{5}$/.test(companyData.postalCode)) {
errors.postalCode = "Kode pos harus 5 digit angka";
if (!companyData.taxid?.trim()) {
errors.taxid = "NPWP/Tax ID wajib diisi";
}
if (!companyData.businessType) {
errors.businessType = "Jenis usaha wajib dipilih";
if (!companyData.foundeddate?.trim()) {
errors.foundeddate = "Tanggal berdiri wajib diisi";
} else {
// Validate date format DD-MM-YYYY
const dateRegex = /^\d{2}-\d{2}-\d{4}$/;
if (!dateRegex.test(companyData.foundeddate)) {
errors.foundeddate = "Format tanggal harus DD-MM-YYYY";
}
}
if (!companyData.employeeCount) {
errors.employeeCount = "Jumlah karyawan wajib dipilih";
if (!companyData.companytype?.trim()) {
errors.companytype = "Jenis perusahaan wajib dipilih";
}
if (!companyData.serviceArea?.trim()) {
errors.serviceArea = "Area layanan wajib diisi";
if (!companyData.companydescription?.trim()) {
errors.companydescription = "Deskripsi perusahaan wajib diisi";
}
if (Object.keys(errors).length > 0) {
return json<CompanyProfileActionData>({ errors }, { status: 400 });
}
// Simulasi menyimpan data - dalam implementasi nyata, simpan ke database
try {
console.log("Saving company profile:", { phone, ...companyData });
// Prepare data untuk API call
const apiData = {
companyname: companyData.companyname.trim(),
companyaddress: companyData.companyaddress.trim(),
companyphone: companyData.companyphone.trim(),
companyemail: companyData.companyemail.trim(),
companywebsite: companyData.companywebsite.trim(),
taxid: companyData.taxid.trim(),
foundeddate: companyData.foundeddate.trim(),
companytype: companyData.companytype.trim(),
companydescription: companyData.companydescription.trim(),
...(companyData.company_logo && {
company_logo: companyData.company_logo
})
};
// Simulasi delay API call
await new Promise((resolve) => setTimeout(resolve, 1000));
// Call API untuk create company profile
const response = await pengelolaAuthService.createCompanyProfile(apiData);
if (response.meta.status === 200 && response.data) {
// Update session dengan data terbaru
return createUserSession({
request,
sessionData: {
...userSession,
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
sessionId: response.data.session_id,
tokenType: response.data.token_type,
registrationStatus: response.data.registration_status,
nextStep: response.data.next_step
},
redirectTo: "/authpengelola/waitingapprovalfromadministrator"
});
} else {
return json<CompanyProfileActionData>(
{
errors: {
general:
response.meta.message || "Gagal menyimpan profil perusahaan"
}
},
{ status: 400 }
);
}
} catch (error: any) {
console.error("Create company profile error:", error);
// Handle specific API errors
if (error.response?.data?.meta?.message) {
return json<CompanyProfileActionData>(
{
errors: { general: error.response.data.meta.message }
},
{ status: error.response.status || 500 }
);
}
// Redirect ke step berikutnya
return redirect(
`/authpengelola/waitingapprovalfromadministrator?phone=${encodeURIComponent(
phone
)}`
);
} catch (error) {
return json<CompanyProfileActionData>(
{
errors: { general: "Gagal menyimpan data. Silakan coba lagi." }
errors: {
general: "Gagal menyimpan profil perusahaan. Silakan coba lagi."
}
},
{ status: 500 }
);
@ -200,12 +284,39 @@ export const action = async ({
};
export default function CompletingCompanyProfile() {
const { phone } = useLoaderData<LoaderData>();
const { userSession } = useLoaderData<LoaderData>();
const actionData = useActionData<CompanyProfileActionData>();
const navigation = useNavigation();
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const isSubmitting = navigation.state === "submitting";
// Handle logo file change
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file type
if (!file.type.startsWith("image/")) {
alert("File harus berupa gambar");
return;
}
// Validate file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
alert("Ukuran file maksimal 2MB");
return;
}
// Create preview
const reader = new FileReader();
reader.onload = (event) => {
setLogoPreview(event.target?.result as string);
};
reader.readAsDataURL(file);
}
};
return (
<div className="space-y-6">
{/* Progress Indicator */}
@ -235,9 +346,11 @@ export default function CompletingCompanyProfile() {
)}
{/* Form */}
<Form method="post" className="space-y-6">
<input type="hidden" name="phone" value={phone} />
<Form
method="post"
encType="multipart/form-data"
className="space-y-6"
>
{/* Company Information Section */}
<div className="space-y-4">
<div className="flex items-center space-x-2 mb-4">
@ -249,246 +362,214 @@ export default function CompletingCompanyProfile() {
{/* Company Name */}
<div className="space-y-2">
<Label htmlFor="companyName">Nama Perusahaan *</Label>
<Label htmlFor="companyname">Nama Perusahaan *</Label>
<Input
id="companyName"
name="companyName"
id="companyname"
name="companyname"
type="text"
placeholder="PT/CV/Koperasi Nama Perusahaan"
className={
actionData?.errors?.companyName ? "border-red-500" : ""
actionData?.errors?.companyname ? "border-red-500" : ""
}
required
/>
{actionData?.errors?.companyName && (
{actionData?.errors?.companyname && (
<p className="text-sm text-red-600">
{actionData.errors.companyName}
{actionData.errors.companyname}
</p>
)}
</div>
{/* Owner Name */}
{/* Company Address */}
<div className="space-y-2">
<Label htmlFor="ownerName">Nama Pemilik/Direktur *</Label>
<Input
id="ownerName"
name="ownerName"
type="text"
placeholder="Nama lengkap pemilik atau direktur"
<Label htmlFor="companyaddress">Alamat Perusahaan *</Label>
<Textarea
id="companyaddress"
name="companyaddress"
placeholder="Alamat lengkap perusahaan"
className={
actionData?.errors?.ownerName ? "border-red-500" : ""
actionData?.errors?.companyaddress ? "border-red-500" : ""
}
rows={3}
required
/>
{actionData?.errors?.companyaddress && (
<p className="text-sm text-red-600">
{actionData.errors.companyaddress}
</p>
)}
</div>
{/* Company Phone */}
<div className="space-y-2">
<Label htmlFor="companyphone">Nomor Telepon Perusahaan *</Label>
<Input
id="companyphone"
name="companyphone"
type="text"
placeholder="628xxxxxxxxx"
className={
actionData?.errors?.companyphone ? "border-red-500" : ""
}
required
/>
{actionData?.errors?.ownerName && (
{actionData?.errors?.companyphone && (
<p className="text-sm text-red-600">
{actionData.errors.ownerName}
{actionData.errors.companyphone}
</p>
)}
</div>
{/* Company Email */}
<div className="space-y-2">
<Label htmlFor="companyemail">Email Perusahaan *</Label>
<Input
id="companyemail"
name="companyemail"
type="email"
placeholder="info@company.com"
className={
actionData?.errors?.companyemail ? "border-red-500" : ""
}
required
/>
{actionData?.errors?.companyemail && (
<p className="text-sm text-red-600">
{actionData.errors.companyemail}
</p>
)}
</div>
{/* Company Website */}
<div className="space-y-2">
<Label htmlFor="companywebsite">Website Perusahaan *</Label>
<Input
id="companywebsite"
name="companywebsite"
type="url"
placeholder="https://company.com"
className={
actionData?.errors?.companywebsite ? "border-red-500" : ""
}
required
/>
{actionData?.errors?.companywebsite && (
<p className="text-sm text-red-600">
{actionData.errors.companywebsite}
</p>
)}
</div>
{/* Tax ID */}
<div className="space-y-2">
<Label htmlFor="taxid">NPWP/Tax ID *</Label>
<Input
id="taxid"
name="taxid"
type="text"
placeholder="123456789123456"
className={actionData?.errors?.taxid ? "border-red-500" : ""}
required
/>
{actionData?.errors?.taxid && (
<p className="text-sm text-red-600">
{actionData.errors.taxid}
</p>
)}
</div>
{/* Founded Date */}
<div className="space-y-2">
<Label htmlFor="foundeddate">Tanggal Berdiri *</Label>
<Input
id="foundeddate"
name="foundeddate"
type="text"
placeholder="DD-MM-YYYY (contoh: 10-09-2015)"
className={
actionData?.errors?.foundeddate ? "border-red-500" : ""
}
required
/>
{actionData?.errors?.foundeddate && (
<p className="text-sm text-red-600">
{actionData.errors.foundeddate}
</p>
)}
</div>
{/* Company Type */}
<div className="space-y-2">
<Label htmlFor="companyType">Jenis Badan Usaha *</Label>
<Select name="companyType" required>
<SelectTrigger
className={
actionData?.errors?.companyType ? "border-red-500" : ""
}
>
<SelectValue placeholder="Pilih jenis badan usaha" />
</SelectTrigger>
<SelectContent>
<SelectItem value="pt">PT (Perseroan Terbatas)</SelectItem>
<SelectItem value="cv">
CV (Commanditaire Vennootschap)
</SelectItem>
<SelectItem value="koperasi">Koperasi</SelectItem>
<SelectItem value="ud">UD (Usaha Dagang)</SelectItem>
<SelectItem value="firma">Firma</SelectItem>
<SelectItem value="yayasan">Yayasan</SelectItem>
<SelectItem value="other">Lainnya</SelectItem>
</SelectContent>
</Select>
{actionData?.errors?.companyType && (
<p className="text-sm text-red-600">
{actionData.errors.companyType}
</p>
)}
</div>
</div>
{/* Address Section */}
<div className="space-y-4">
<div className="flex items-center space-x-2 mb-4">
<MapPin className="h-5 w-5 text-gray-600" />
<h3 className="text-lg font-medium text-gray-900">
Alamat Perusahaan
</h3>
</div>
{/* Address */}
<div className="space-y-2">
<Label htmlFor="address">Alamat Lengkap *</Label>
<Textarea
id="address"
name="address"
placeholder="Jalan, nomor, RT/RW, Kelurahan, Kecamatan"
className={
actionData?.errors?.address ? "border-red-500" : ""
}
rows={3}
required
/>
{actionData?.errors?.address && (
<p className="text-sm text-red-600">
{actionData.errors.address}
</p>
)}
</div>
{/* City and Postal Code */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="city">Kota *</Label>
<Input
id="city"
name="city"
type="text"
placeholder="Jakarta/Bandung/Surabaya/dll"
className={actionData?.errors?.city ? "border-red-500" : ""}
required
/>
{actionData?.errors?.city && (
<p className="text-sm text-red-600">
{actionData.errors.city}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="postalCode">Kode Pos *</Label>
<Input
id="postalCode"
name="postalCode"
type="text"
placeholder="12345"
maxLength={5}
className={
actionData?.errors?.postalCode ? "border-red-500" : ""
}
required
/>
{actionData?.errors?.postalCode && (
<p className="text-sm text-red-600">
{actionData.errors.postalCode}
</p>
)}
</div>
</div>
</div>
{/* Business Information Section */}
<div className="space-y-4">
<div className="flex items-center space-x-2 mb-4">
<FileText className="h-5 w-5 text-gray-600" />
<h3 className="text-lg font-medium text-gray-900">
Informasi Usaha
</h3>
</div>
{/* Business Type */}
<div className="space-y-2">
<Label htmlFor="businessType">
Jenis Usaha Pengelolaan Sampah *
</Label>
<Select name="businessType" required>
<SelectTrigger
className={
actionData?.errors?.businessType ? "border-red-500" : ""
}
>
<SelectValue placeholder="Pilih jenis usaha" />
</SelectTrigger>
<SelectContent>
<SelectItem value="collection">
Pengumpulan & Pengangkutan
</SelectItem>
<SelectItem value="processing">
Pengolahan & Daur Ulang
</SelectItem>
<SelectItem value="disposal">Pembuangan Akhir</SelectItem>
<SelectItem value="integrated">
Terintegrasi (Semua Layanan)
</SelectItem>
<SelectItem value="consulting">
Konsultan Pengelolaan Sampah
</SelectItem>
</SelectContent>
</Select>
{actionData?.errors?.businessType && (
<p className="text-sm text-red-600">
{actionData.errors.businessType}
</p>
)}
</div>
{/* Employee Count */}
<div className="space-y-2">
<Label htmlFor="employeeCount">Jumlah Karyawan *</Label>
<Select name="employeeCount" required>
<SelectTrigger
className={
actionData?.errors?.employeeCount ? "border-red-500" : ""
}
>
<SelectValue placeholder="Pilih jumlah karyawan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1-5">1-5 orang</SelectItem>
<SelectItem value="6-10">6-10 orang</SelectItem>
<SelectItem value="11-25">11-25 orang</SelectItem>
<SelectItem value="26-50">26-50 orang</SelectItem>
<SelectItem value="51-100">51-100 orang</SelectItem>
<SelectItem value="100+">Lebih dari 100 orang</SelectItem>
</SelectContent>
</Select>
{actionData?.errors?.employeeCount && (
<p className="text-sm text-red-600">
{actionData.errors.employeeCount}
</p>
)}
</div>
{/* Service Area */}
<div className="space-y-2">
<Label htmlFor="serviceArea">Area Layanan *</Label>
<Label htmlFor="companytype">Jenis Perusahaan *</Label>
<Input
id="serviceArea"
name="serviceArea"
id="companytype"
name="companytype"
type="text"
placeholder="Jakarta Utara, Bekasi, Tangerang, dll"
placeholder="Waste recycle"
className={
actionData?.errors?.serviceArea ? "border-red-500" : ""
actionData?.errors?.companytype ? "border-red-500" : ""
}
required
/>
{actionData?.errors?.serviceArea && (
{actionData?.errors?.companytype && (
<p className="text-sm text-red-600">
{actionData.errors.serviceArea}
{actionData.errors.companytype}
</p>
)}
</div>
{/* Description */}
{/* Company Description */}
<div className="space-y-2">
<Label htmlFor="description">Deskripsi Usaha (Opsional)</Label>
<Label htmlFor="companydescription">
Deskripsi Perusahaan *
</Label>
<Textarea
id="description"
name="description"
placeholder="Ceritakan lebih detail tentang usaha pengelolaan sampah Anda..."
rows={3}
id="companydescription"
name="companydescription"
placeholder="Ceritakan tentang perusahaan Anda..."
className={
actionData?.errors?.companydescription
? "border-red-500"
: ""
}
rows={4}
required
/>
{actionData?.errors?.companydescription && (
<p className="text-sm text-red-600">
{actionData.errors.companydescription}
</p>
)}
</div>
{/* Company Logo */}
<div className="space-y-2">
<Label htmlFor="company_logo">Logo Perusahaan (Opsional)</Label>
<div className="space-y-3">
<Input
id="company_logo"
name="company_logo"
type="file"
accept="image/*"
onChange={handleLogoChange}
className="cursor-pointer"
/>
{logoPreview && (
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
<img
src={logoPreview}
alt="Logo preview"
className="w-16 h-16 object-cover rounded-lg border"
/>
<div>
<p className="text-sm font-medium">Preview Logo</p>
<p className="text-xs text-gray-500">
Ukuran maksimal 2MB, format JPG/PNG
</p>
</div>
</div>
)}
</div>
</div>
</div>
@ -532,13 +613,11 @@ export default function CompletingCompanyProfile() {
{/* Back Link */}
<div className="text-center">
<Link
to={`/authpengelola/verifyotptoregister?phone=${encodeURIComponent(
phone
)}`}
to="/authpengelola"
className="inline-flex items-center text-sm text-gray-600 hover:text-green-600 transition-colors"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Kembali ke verifikasi OTP
Kembali ke halaman utama
</Link>
</div>
</CardContent>

View File

@ -8,7 +8,8 @@ import {
Form,
useActionData,
useLoaderData,
useNavigation
useNavigation,
Link
} from "@remix-run/react";
import { useState, useRef } from "react";
import { Card, CardContent, CardHeader } from "~/components/ui/card";
@ -18,14 +19,19 @@ import { Label } from "~/components/ui/label";
import { Alert, AlertDescription } from "~/components/ui/alert";
import {
Shield,
CheckCircle,
ArrowLeft,
ArrowRight,
AlertCircle,
Loader2,
Lock,
CheckCircle,
Eye,
EyeOff,
Sparkles
Lock,
Star
} from "lucide-react";
import { validatePin } from "~/utils/auth-utils";
import pengelolaAuthService from "~/services/auth/pengelola.service";
import { getUserSession, createUserSession } from "~/sessions.server";
// Progress Indicator Component
const ProgressIndicator = ({ currentStep = 5, totalSteps = 5 }) => {
@ -50,7 +56,11 @@ const ProgressIndicator = ({ currentStep = 5, totalSteps = 5 }) => {
}
`}
>
{isCompleted ? <CheckCircle className="h-5 w-5" /> : stepNumber}
{isCompleted || isActive ? (
<CheckCircle className="h-5 w-5" />
) : (
stepNumber
)}
</div>
{stepNumber < totalSteps && (
<div
@ -68,13 +78,12 @@ const ProgressIndicator = ({ currentStep = 5, totalSteps = 5 }) => {
// Interfaces
interface LoaderData {
phone: string;
approvedAt: string;
userSession: any;
}
interface CreatePINActionData {
interface CreatePinActionData {
errors?: {
pin?: string;
userpin?: string;
confirmPin?: string;
general?: string;
};
@ -84,61 +93,106 @@ interface CreatePINActionData {
export const loader = async ({
request
}: LoaderFunctionArgs): Promise<Response> => {
const url = new URL(request.url);
const phone = url.searchParams.get("phone");
const userSession = await getUserSession(request);
if (!phone) {
return redirect("/authpengelola/requestotpforregister");
// Check if user is authenticated and has pengelola role
if (!userSession || userSession.role !== "pengelola") {
return redirect("/authpengelola");
}
return json<LoaderData>({
phone,
approvedAt: new Date().toISOString()
});
// Check if user should be on this step
if (userSession.registrationStatus !== "approved") {
// Redirect based on current status
switch (userSession.registrationStatus) {
case "uncomplete":
return redirect("/authpengelola/completingcompanyprofile");
case "awaiting_approval":
return redirect("/authpengelola/waitingapprovalfromadministrator");
case "complete":
return redirect("/pengelola/dashboard");
default:
break;
}
}
return json<LoaderData>({ userSession });
};
export const action = async ({
request
}: ActionFunctionArgs): Promise<Response> => {
const userSession = await getUserSession(request);
if (!userSession || userSession.role !== "pengelola") {
return redirect("/authpengelola");
}
const formData = await request.formData();
const phone = formData.get("phone") as string;
const pin = formData.get("pin") as string;
const userpin = formData.get("userpin") as string;
const confirmPin = formData.get("confirmPin") as string;
// Validation
const errors: { pin?: string; confirmPin?: string; general?: string } = {};
const errors: { [key: string]: string } = {};
if (!pin || pin.length !== 6) {
errors.pin = "PIN harus 6 digit";
} else if (!/^\d{6}$/.test(pin)) {
errors.pin = "PIN hanya boleh berisi angka";
} else if (/^(.)\1{5}$/.test(pin)) {
errors.pin = "PIN tidak boleh angka yang sama semua (111111)";
} else if (pin === "123456" || pin === "654321" || pin === "000000") {
errors.pin = "PIN terlalu mudah ditebak, gunakan kombinasi yang lebih aman";
if (!userpin) {
errors.userpin = "PIN wajib diisi";
} else if (!validatePin(userpin)) {
errors.userpin = "PIN harus 6 digit angka";
}
if (!confirmPin) {
errors.confirmPin = "Konfirmasi PIN wajib diisi";
} else if (pin !== confirmPin) {
} else if (userpin !== confirmPin) {
errors.confirmPin = "PIN dan konfirmasi PIN tidak sama";
}
if (Object.keys(errors).length > 0) {
return json<CreatePINActionData>({ errors }, { status: 400 });
return json<CreatePinActionData>({ errors }, { status: 400 });
}
// Simulasi menyimpan PIN - dalam implementasi nyata, hash dan simpan ke database
try {
console.log("Creating PIN for phone:", phone);
// Call API untuk create PIN
const response = await pengelolaAuthService.createPin({
userpin
});
// Simulasi delay API call
await new Promise((resolve) => setTimeout(resolve, 1500));
if (response.meta.status === 200 && response.data) {
// PIN berhasil dibuat, update session dan redirect ke dashboard
return createUserSession({
request,
sessionData: {
...userSession,
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
sessionId: response.data.session_id,
tokenType: response.data.token_type,
registrationStatus: response.data.registration_status,
nextStep: response.data.next_step
},
redirectTo: "/pengelola/dashboard"
});
} else {
return json<CreatePinActionData>(
{
errors: { general: response.meta.message || "Gagal membuat PIN" }
},
{ status: 400 }
);
}
} catch (error: any) {
console.error("Create PIN error:", error);
// Redirect ke dashboard pengelola setelah berhasil
return redirect("/pengelola/dashboard");
} catch (error) {
return json<CreatePINActionData>(
// Handle specific API errors
if (error.response?.data?.meta?.message) {
return json<CreatePinActionData>(
{
errors: { general: error.response.data.meta.message }
},
{ status: error.response.status || 500 }
);
}
return json<CreatePinActionData>(
{
errors: { general: "Gagal membuat PIN. Silakan coba lagi." }
},
@ -147,15 +201,15 @@ export const action = async ({
}
};
export default function CreateANewPIN() {
const { phone, approvedAt } = useLoaderData<LoaderData>();
const actionData = useActionData<CreatePINActionData>();
export default function CreateANewPin() {
const { userSession } = useLoaderData<LoaderData>();
const actionData = useActionData<CreatePinActionData>();
const navigation = useNavigation();
const [pin, setPin] = useState(["", "", "", "", "", ""]);
const [confirmPin, setConfirmPin] = useState(["", "", "", "", "", ""]);
const [showPin, setShowPin] = useState(false);
const [pinStrength, setPinStrength] = useState(0);
const [showConfirmPin, setShowConfirmPin] = useState(false);
const pinRefs = useRef<(HTMLInputElement | null)[]>([]);
const confirmPinRefs = useRef<(HTMLInputElement | null)[]>([]);
@ -166,23 +220,20 @@ export default function CreateANewPIN() {
const handlePinChange = (
index: number,
value: string,
isConfirm: boolean = false
isPinField: boolean = true
) => {
if (!/^\d*$/.test(value)) return; // Only allow digits
const newPin = isConfirm ? [...confirmPin] : [...pin];
newPin[index] = value;
const currentPin = isPinField ? pin : confirmPin;
const setCurrentPin = isPinField ? setPin : setConfirmPin;
const refs = isPinField ? pinRefs : confirmPinRefs;
if (isConfirm) {
setConfirmPin(newPin);
} else {
setPin(newPin);
calculatePinStrength(newPin.join(""));
}
const newPin = [...currentPin];
newPin[index] = value;
setCurrentPin(newPin);
// Auto-focus next input
if (value && index < 5) {
const refs = isConfirm ? confirmPinRefs : pinRefs;
refs.current[index + 1]?.focus();
}
};
@ -191,115 +242,44 @@ export default function CreateANewPIN() {
const handleKeyDown = (
index: number,
e: React.KeyboardEvent,
isConfirm: boolean = false
isPinField: boolean = true
) => {
if (e.key === "Backspace") {
const currentPin = isConfirm ? confirmPin : pin;
const refs = isConfirm ? confirmPinRefs : pinRefs;
const currentPin = isPinField ? pin : confirmPin;
const refs = isPinField ? pinRefs : confirmPinRefs;
if (!currentPin[index] && index > 0) {
refs.current[index - 1]?.focus();
if (e.key === "Backspace" && !currentPin[index] && index > 0) {
refs.current[index - 1]?.focus();
}
};
// Handle paste
const handlePaste = (e: React.ClipboardEvent, isPinField: boolean = true) => {
e.preventDefault();
const pastedText = e.clipboardData.getData("text");
const digits = pastedText.replace(/\D/g, "").slice(0, 6);
if (digits.length === 6) {
const newPin = digits.split("");
if (isPinField) {
setPin(newPin);
pinRefs.current[5]?.focus();
} else {
setConfirmPin(newPin);
confirmPinRefs.current[5]?.focus();
}
}
};
// Calculate PIN strength
const calculatePinStrength = (pinValue: string) => {
if (pinValue.length < 6) {
setPinStrength(0);
return;
}
let strength = 0;
// Check for sequential numbers
const isSequential =
/012345|123456|234567|345678|456789|987654|876543|765432|654321|543210/.test(
pinValue
);
if (!isSequential) strength += 25;
// Check for repeated numbers
const hasRepeated = /(.)\1{2,}/.test(pinValue);
if (!hasRepeated) strength += 25;
// Check for common patterns
const isCommon = [
"123456",
"654321",
"111111",
"000000",
"222222",
"333333",
"444444",
"555555",
"666666",
"777777",
"888888",
"999999"
].includes(pinValue);
if (!isCommon) strength += 25;
// Check for variety
const uniqueDigits = new Set(pinValue.split("")).size;
if (uniqueDigits >= 4) strength += 25;
setPinStrength(strength);
};
// Get strength color and text
const getStrengthInfo = () => {
if (pinStrength === 0)
return {
color: "bg-gray-200",
text: "Masukkan PIN",
textColor: "text-gray-500"
};
if (pinStrength <= 25)
return { color: "bg-red-500", text: "Lemah", textColor: "text-red-600" };
if (pinStrength <= 50)
return {
color: "bg-yellow-500",
text: "Sedang",
textColor: "text-yellow-600"
};
if (pinStrength <= 75)
return {
color: "bg-blue-500",
text: "Bagus",
textColor: "text-blue-600"
};
return {
color: "bg-green-500",
text: "Sangat Kuat",
textColor: "text-green-600"
};
};
const strengthInfo = getStrengthInfo();
const fullPin = pin.join("");
const fullConfirmPin = confirmPin.join("");
const pinValue = pin.join("");
const confirmPinValue = confirmPin.join("");
const isPinComplete = pinValue.length === 6 && confirmPinValue.length === 6;
const isPinMatching = pinValue === confirmPinValue && pinValue.length === 6;
return (
<div className="space-y-6">
{/* Progress Indicator */}
<ProgressIndicator currentStep={5} totalSteps={5} />
{/* Success Alert */}
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center space-x-3">
<CheckCircle className="h-6 w-6 text-green-600" />
<div>
<p className="font-medium text-green-800">
Selamat! Akun Anda Telah Disetujui
</p>
<p className="text-sm text-green-700">
Administrator telah memverifikasi dan menyetujui aplikasi Anda
</p>
</div>
</div>
</div>
{/* Main Card */}
<Card className="border-0 shadow-2xl bg-white/80 backdrop-blur-sm">
<CardHeader className="text-center pb-2">
@ -310,7 +290,7 @@ export default function CreateANewPIN() {
Buat PIN Keamanan
</h1>
<p className="text-muted-foreground mt-2">
Langkah terakhir untuk mengamankan akun Anda
Langkah terakhir! Buat PIN 6 digit untuk mengamankan akun Anda
</p>
</CardHeader>
@ -325,137 +305,143 @@ export default function CreateANewPIN() {
{/* Form */}
<Form method="post" className="space-y-6">
<input type="hidden" name="phone" value={phone} />
<input type="hidden" name="pin" value={fullPin} />
<input type="hidden" name="confirmPin" value={fullConfirmPin} />
<input type="hidden" name="userpin" value={pinValue} />
<input type="hidden" name="confirmPin" value={confirmPinValue} />
{/* PIN Input */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">PIN 6 Digit</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowPin(!showPin)}
className="text-gray-500 hover:text-gray-700"
>
{showPin ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<div className="flex justify-center space-x-3">
{pin.map((digit, index) => (
<Input
key={index}
ref={(el) => (pinRefs.current[index] = el)}
type={showPin ? "text" : "password"}
maxLength={1}
value={digit}
onChange={(e) => handlePinChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
className={`w-12 h-12 text-center text-lg font-bold ${
actionData?.errors?.pin ? "border-red-500" : ""
}`}
autoFocus={index === 0}
/>
))}
</div>
{actionData?.errors?.pin && (
<p className="text-sm text-red-600 text-center">
{actionData.errors.pin}
</p>
)}
{/* PIN Strength Indicator */}
{fullPin.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Kekuatan PIN</span>
<span
className={`text-sm font-medium ${strengthInfo.textColor}`}
>
{strengthInfo.text}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${strengthInfo.color}`}
style={{ width: `${pinStrength}%` }}
></div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
Masukkan PIN Baru
</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowPin(!showPin)}
className="h-8 px-2 text-gray-500 hover:text-gray-700"
>
{showPin ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
)}
<div className="flex justify-center space-x-3">
{pin.map((digit, index) => (
<Input
key={index}
ref={(el) => (pinRefs.current[index] = el)}
type={showPin ? "text" : "password"}
maxLength={1}
value={digit}
onChange={(e) =>
handlePinChange(index, e.target.value, true)
}
onKeyDown={(e) => handleKeyDown(index, e, true)}
onPaste={(e) => handlePaste(e, true)}
className={`w-14 h-14 text-center text-xl font-bold ${
actionData?.errors?.userpin ? "border-red-500" : ""
}`}
autoFocus={index === 0}
/>
))}
</div>
{actionData?.errors?.userpin && (
<p className="text-sm text-red-600 text-center">
{actionData.errors.userpin}
</p>
)}
</div>
{/* Confirm PIN Input */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
Konfirmasi PIN
</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowConfirmPin(!showConfirmPin)}
className="h-8 px-2 text-gray-500 hover:text-gray-700"
>
{showConfirmPin ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<div className="flex justify-center space-x-3">
{confirmPin.map((digit, index) => (
<Input
key={index}
ref={(el) => (confirmPinRefs.current[index] = el)}
type={showConfirmPin ? "text" : "password"}
maxLength={1}
value={digit}
onChange={(e) =>
handlePinChange(index, e.target.value, false)
}
onKeyDown={(e) => handleKeyDown(index, e, false)}
onPaste={(e) => handlePaste(e, false)}
className={`w-14 h-14 text-center text-xl font-bold ${
actionData?.errors?.confirmPin ? "border-red-500" : ""
} ${
isPinComplete && !isPinMatching ? "border-red-500" : ""
} ${isPinMatching ? "border-green-500" : ""}`}
/>
))}
</div>
{actionData?.errors?.confirmPin && (
<p className="text-sm text-red-600 text-center">
{actionData.errors.confirmPin}
</p>
)}
{isPinComplete &&
!isPinMatching &&
!actionData?.errors?.confirmPin && (
<p className="text-sm text-red-600 text-center">
PIN tidak sama, silakan periksa kembali
</p>
)}
{isPinMatching && (
<div className="flex items-center justify-center space-x-2 text-sm text-green-600">
<CheckCircle className="h-4 w-4" />
<span>PIN cocok!</span>
</div>
)}
</div>
</div>
{/* Confirm PIN Input */}
<div className="space-y-4">
<Label className="text-base font-medium">Konfirmasi PIN</Label>
<div className="flex justify-center space-x-3">
{confirmPin.map((digit, index) => (
<Input
key={index}
ref={(el) => (confirmPinRefs.current[index] = el)}
type={showPin ? "text" : "password"}
maxLength={1}
value={digit}
onChange={(e) =>
handlePinChange(index, e.target.value, true)
}
onKeyDown={(e) => handleKeyDown(index, e, true)}
className={`w-12 h-12 text-center text-lg font-bold ${
actionData?.errors?.confirmPin ? "border-red-500" : ""
}`}
/>
))}
</div>
{actionData?.errors?.confirmPin && (
<p className="text-sm text-red-600 text-center">
{actionData.errors.confirmPin}
</p>
)}
{/* PIN Match Indicator */}
{fullPin.length === 6 && fullConfirmPin.length === 6 && (
<div className="text-center">
{fullPin === fullConfirmPin ? (
<div className="flex items-center justify-center space-x-2 text-green-600">
<CheckCircle className="h-4 w-4" />
<span className="text-sm font-medium">PIN cocok</span>
</div>
) : (
<div className="flex items-center justify-center space-x-2 text-red-600">
<AlertCircle className="h-4 w-4" />
<span className="text-sm font-medium">
PIN tidak cocok
</span>
</div>
)}
</div>
)}
</div>
{/* PIN Guidelines */}
{/* PIN Requirements */}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start space-x-3">
<Lock className="h-5 w-5 text-blue-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-800 mb-2">
Tips PIN yang Aman:
<p className="text-sm font-medium text-blue-800">
Syarat PIN Keamanan
</p>
<ul className="text-xs text-blue-700 space-y-1">
<li> Hindari angka berurutan (123456, 654321)</li>
<li> Jangan gunakan angka yang sama semua (111111)</li>
<li> Hindari kombinasi mudah ditebak (000000, 123456)</li>
<li> Gunakan kombinasi angka yang hanya Anda ketahui</li>
</ul>
<div className="text-xs text-blue-700 mt-1 space-y-1">
<p> Harus terdiri dari 6 digit angka</p>
<p>
Hindari urutan angka (123456) atau angka sama (111111)
</p>
<p> Jangan gunakan tanggal lahir atau nomor telepon</p>
<p>
PIN akan digunakan untuk verifikasi transaksi penting
</p>
</div>
</div>
</div>
</div>
@ -464,38 +450,47 @@ export default function CreateANewPIN() {
<Button
type="submit"
className="w-full h-12 bg-gradient-to-r from-green-600 to-blue-600 hover:from-green-700 hover:to-blue-700 text-white shadow-lg"
disabled={
isSubmitting ||
fullPin.length !== 6 ||
fullConfirmPin.length !== 6 ||
fullPin !== fullConfirmPin ||
pinStrength < 50
}
disabled={!isPinMatching || isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Membuat Akun...
Membuat PIN...
</>
) : (
<>
<Sparkles className="mr-2 h-5 w-5" />
<Star className="mr-2 h-5 w-5" />
Selesaikan Registrasi
<ArrowRight className="ml-2 h-5 w-5" />
</>
)}
</Button>
</Form>
{/* Final Note */}
<div className="p-4 bg-gradient-to-r from-green-50 to-blue-50 border border-green-200 rounded-lg">
<div className="text-center">
<p className="text-sm font-medium text-gray-800 mb-1">
🎉 Hampir selesai!
</p>
<p className="text-xs text-gray-600">
Setelah membuat PIN, Anda akan langsung dapat mengakses
dashboard pengelola
</p>
{/* Back Link */}
<div className="text-center">
<Link
to="/authpengelola/waitingapprovalfromadministrator"
className="inline-flex items-center text-sm text-gray-600 hover:text-green-600 transition-colors"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Kembali ke status persetujuan
</Link>
</div>
</CardContent>
</Card>
{/* Security Tips */}
<Card className="border border-gray-200 bg-white/60 backdrop-blur-sm">
<CardContent className="p-4">
<div className="text-center space-y-2">
<p className="text-sm font-medium text-gray-700">
🔒 Tips Keamanan PIN
</p>
<div className="text-xs text-gray-600 space-y-1">
<p> Jangan berikan PIN kepada siapa pun</p>
<p> Ganti PIN secara berkala untuk keamanan optimal</p>
<p> PIN dapat diubah melalui menu pengaturan setelah login</p>
</div>
</div>
</CardContent>

View File

@ -6,16 +6,17 @@ import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Progress } from "~/components/ui/progress";
import {
Phone,
ArrowLeft,
ArrowRight,
AlertCircle,
import {
Phone,
ArrowLeft,
ArrowRight,
AlertCircle,
Loader2,
MessageSquare,
CheckCircle
} from "lucide-react";
import { validatePhoneNumber } from "~/utils/auth-utils";
import pengelolaAuthService from "~/services/auth/pengelola.service";
// Progress Indicator Component
const ProgressIndicator = ({ currentStep = 1, totalSteps = 5 }) => {
@ -25,30 +26,27 @@ const ProgressIndicator = ({ currentStep = 1, totalSteps = 5 }) => {
const stepNumber = index + 1;
const isActive = stepNumber === currentStep;
const isCompleted = stepNumber < currentStep;
return (
<div key={stepNumber} className="flex items-center">
<div
className={`
w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium
${isActive
? 'bg-gradient-to-r from-green-600 to-blue-600 text-white shadow-lg'
: isCompleted
? 'bg-green-100 text-green-600 border-2 border-green-200'
: 'bg-gray-100 text-gray-400 border-2 border-gray-200'
${
isActive
? "bg-gradient-to-r from-green-600 to-blue-600 text-white shadow-lg"
: isCompleted
? "bg-green-100 text-green-600 border-2 border-green-200"
: "bg-gray-100 text-gray-400 border-2 border-gray-200"
}
`}
>
{isCompleted ? (
<CheckCircle className="h-5 w-5" />
) : (
stepNumber
)}
{isCompleted ? <CheckCircle className="h-5 w-5" /> : stepNumber}
</div>
{stepNumber < totalSteps && (
<div
<div
className={`w-8 h-0.5 mx-2 ${
stepNumber < currentStep ? 'bg-green-400' : 'bg-gray-200'
stepNumber < currentStep ? "bg-green-400" : "bg-gray-200"
}`}
/>
)}
@ -68,40 +66,64 @@ interface RequestOTPActionData {
success?: boolean;
}
export const action = async ({ request }: ActionFunctionArgs): Promise<Response> => {
export const action = async ({
request
}: ActionFunctionArgs): Promise<Response> => {
const formData = await request.formData();
const phone = formData.get("phone") as string;
// Validation
const errors: { phone?: string; general?: string } = {};
if (!phone) {
errors.phone = "Nomor WhatsApp wajib diisi";
} else {
// Validasi format nomor HP Indonesia
const phoneRegex = /^62[0-9]{9,14}$/;
if (!phoneRegex.test(phone)) {
errors.phone = "Format: 628xxxxxxxxx (9-14 digit setelah 62)";
}
} else if (!validatePhoneNumber(phone)) {
errors.phone = "Format: 628xxxxxxxxx (9-14 digit setelah 62)";
}
if (Object.keys(errors).length > 0) {
return json<RequestOTPActionData>({ errors }, { status: 400 });
}
// Simulasi kirim OTP - dalam implementasi nyata, integrate dengan WhatsApp Business API
try {
console.log("Sending OTP to WhatsApp:", phone);
// Simulasi delay API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Redirect ke step berikutnya dengan nomor HP
return redirect(`/authpengelola/verifyotptoregister?phone=${encodeURIComponent(phone)}`);
} catch (error) {
return json<RequestOTPActionData>({
errors: { general: "Gagal mengirim OTP. Silakan coba lagi." }
}, { status: 500 });
// Call API untuk request OTP register
const response = await pengelolaAuthService.requestOtpRegister({
phone,
role_name: "pengelola"
});
if (response.meta.status === 200) {
// OTP berhasil dikirim, redirect ke halaman verifikasi
return redirect(
`/authpengelola/verifyotptoregister?phone=${encodeURIComponent(phone)}`
);
} else {
return json<RequestOTPActionData>(
{
errors: { general: response.meta.message || "Gagal mengirim OTP" }
},
{ status: 400 }
);
}
} catch (error: any) {
console.error("Request OTP error:", error);
// Handle specific API errors
if (error.response?.data?.meta?.message) {
return json<RequestOTPActionData>(
{
errors: { general: error.response.data.meta.message }
},
{ status: error.response.status || 500 }
);
}
return json<RequestOTPActionData>(
{
errors: { general: "Gagal mengirim OTP. Silakan coba lagi." }
},
{ status: 500 }
);
}
};
@ -109,39 +131,47 @@ export default function RequestOTPForRegister() {
const actionData = useActionData<RequestOTPActionData>();
const navigation = useNavigation();
const [phone, setPhone] = useState("");
const isSubmitting = navigation.state === "submitting";
// Format input nomor HP
const handlePhoneChange = (value: string) => {
// Remove non-numeric characters
let cleaned = value.replace(/\D/g, '');
let cleaned = value.replace(/\D/g, "");
// Ensure starts with 62
if (cleaned.length > 0 && !cleaned.startsWith('62')) {
if (cleaned.startsWith('0')) {
cleaned = '62' + cleaned.substring(1);
} else if (cleaned.startsWith('8')) {
cleaned = '62' + cleaned;
if (cleaned.length > 0 && !cleaned.startsWith("62")) {
if (cleaned.startsWith("0")) {
cleaned = "62" + cleaned.substring(1);
} else if (cleaned.startsWith("8")) {
cleaned = "62" + cleaned;
} else {
cleaned = '62' + cleaned;
cleaned = "62" + cleaned;
}
}
// Limit length
if (cleaned.length > 16) {
cleaned = cleaned.substring(0, 16);
}
setPhone(cleaned);
};
// Format display nomor HP
const formatPhoneDisplay = (value: string) => {
if (value.length <= 2) return value;
if (value.length <= 5) return `${value.substring(0, 2)} ${value.substring(2)}`;
if (value.length <= 9) return `${value.substring(0, 2)} ${value.substring(2, 5)} ${value.substring(5)}`;
return `${value.substring(0, 2)} ${value.substring(2, 5)} ${value.substring(5, 9)} ${value.substring(9)}`;
if (value.length <= 5)
return `${value.substring(0, 2)} ${value.substring(2)}`;
if (value.length <= 9)
return `${value.substring(0, 2)} ${value.substring(
2,
5
)} ${value.substring(5)}`;
return `${value.substring(0, 2)} ${value.substring(2, 5)} ${value.substring(
5,
9
)} ${value.substring(9)}`;
};
return (
@ -168,9 +198,7 @@ export default function RequestOTPForRegister() {
{actionData?.errors?.general && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{actionData.errors.general}
</AlertDescription>
<AlertDescription>{actionData.errors.general}</AlertDescription>
</Alert>
)}
@ -190,22 +218,24 @@ export default function RequestOTPForRegister() {
value={phone}
onChange={(e) => handlePhoneChange(e.target.value)}
className={`pl-12 h-12 text-lg ${
actionData?.errors?.phone ? 'border-red-500' : ''
actionData?.errors?.phone ? "border-red-500" : ""
}`}
maxLength={16}
required
/>
</div>
{/* Display formatted phone */}
{phone.length > 2 && (
<p className="text-sm text-gray-600">
Format: {formatPhoneDisplay(phone)}
</p>
)}
{actionData?.errors?.phone && (
<p className="text-sm text-red-600">{actionData.errors.phone}</p>
<p className="text-sm text-red-600">
{actionData.errors.phone}
</p>
)}
</div>
@ -218,8 +248,8 @@ export default function RequestOTPForRegister() {
Verifikasi WhatsApp
</p>
<p className="text-xs text-blue-700 mt-1">
Kode OTP akan dikirim ke nomor WhatsApp Anda.
Pastikan nomor yang dimasukkan aktif dan dapat menerima pesan.
Kode OTP akan dikirim ke nomor WhatsApp Anda. Pastikan nomor
yang dimasukkan aktif dan dapat menerima pesan.
</p>
</div>
</div>
@ -227,7 +257,9 @@ export default function RequestOTPForRegister() {
{/* Format Guide */}
<div className="p-3 bg-gray-50 rounded-lg">
<p className="text-xs font-medium text-gray-700 mb-2">Format Nomor Yang Benar:</p>
<p className="text-xs font-medium text-gray-700 mb-2">
Format Nomor Yang Benar:
</p>
<div className="space-y-1 text-xs text-gray-600">
<p> Dimulai dengan 62 (kode negara Indonesia)</p>
<p> Contoh: 628123456789 (untuk 0812-3456-789)</p>
@ -236,8 +268,8 @@ export default function RequestOTPForRegister() {
</div>
{/* Submit Button */}
<Button
type="submit"
<Button
type="submit"
className="w-full h-12 bg-gradient-to-r from-green-600 to-blue-600 hover:from-green-700 hover:to-blue-700 text-white shadow-lg"
disabled={isSubmitting || phone.length < 11}
>
@ -257,7 +289,7 @@ export default function RequestOTPForRegister() {
{/* Back Link */}
<div className="text-center">
<Link
<Link
to="/authpengelola"
className="inline-flex items-center text-sm text-gray-600 hover:text-green-600 transition-colors"
>
@ -272,11 +304,9 @@ export default function RequestOTPForRegister() {
<Card className="border border-gray-200 bg-white/60 backdrop-blur-sm">
<CardContent className="p-4">
<div className="text-center">
<p className="text-sm text-gray-600 mb-2">
Mengalami kesulitan?
</p>
<a
href="https://wa.me/6281234567890?text=Halo%20saya%20butuh%20bantuan%20registrasi%20pengelola"
<p className="text-sm text-gray-600 mb-2">Mengalami kesulitan?</p>
<a
href="https://wa.me/6281234567890?text=Halo%20saya%20butuh%20bantuan%20registrasi%20pengelola"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-green-600 hover:text-green-800 font-medium"
@ -288,4 +318,4 @@ export default function RequestOTPForRegister() {
</Card>
</div>
);
}
}

View File

@ -27,8 +27,11 @@ import {
CheckCircle,
Smartphone
} from "lucide-react";
import { validateOtp, generateDeviceId } from "~/utils/auth-utils";
import pengelolaAuthService from "~/services/auth/pengelola.service";
import { createUserSession } from "~/sessions.server";
// Progress Indicator Component (reuse dari step sebelumnya)
// Progress Indicator Component
const ProgressIndicator = ({ currentStep = 2, totalSteps = 5 }) => {
return (
<div className="flex items-center justify-center space-x-2 mb-8">
@ -110,46 +113,115 @@ export const action = async ({
const actionType = formData.get("_action") as string;
if (actionType === "resend") {
// Simulasi resend OTP
console.log("Resending OTP to WhatsApp:", phone);
try {
// Call API untuk resend OTP
const response = await pengelolaAuthService.requestOtpRegister({
phone,
role_name: "pengelola"
});
return json<VerifyOTPActionData>({
success: true,
message: "Kode OTP baru telah dikirim ke WhatsApp Anda",
otpSentAt: new Date().toISOString()
});
if (response.meta.status === 200) {
return json<VerifyOTPActionData>({
success: true,
message: "Kode OTP baru telah dikirim ke WhatsApp Anda",
otpSentAt: new Date().toISOString()
});
} else {
return json<VerifyOTPActionData>(
{
errors: {
general: response.meta.message || "Gagal mengirim ulang OTP"
}
},
{ status: 400 }
);
}
} catch (error: any) {
console.error("Resend OTP error:", error);
return json<VerifyOTPActionData>(
{
errors: { general: "Gagal mengirim ulang OTP. Silakan coba lagi." }
},
{ status: 500 }
);
}
}
if (actionType === "verify") {
// Validation
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 (!otp) {
errors.otp = "Kode OTP wajib diisi";
} else if (!validateOtp(otp)) {
errors.otp = "Kode OTP harus 4 digit angka";
}
if (Object.keys(errors).length > 0) {
return json<VerifyOTPActionData>({ errors }, { status: 400 });
}
// Simulasi verifikasi OTP - dalam implementasi nyata, cek ke database/cache
if (otp === "1234") {
// OTP valid, lanjut ke step berikutnya
return redirect(
`/authpengelola/completingcompanyprofile?phone=${encodeURIComponent(
phone
)}`
try {
// Generate device ID
const deviceId = generateDeviceId("pengelola_");
// Call API untuk verifikasi OTP
const response = await pengelolaAuthService.verifyOtpRegister({
phone,
otp,
device_id: deviceId,
role_name: "pengelola"
});
if (response.meta.status === 200 && response.data) {
// OTP valid, create session
return createUserSession({
request,
sessionData: {
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
sessionId: response.data.session_id,
role: "pengelola",
deviceId: deviceId,
phone: phone,
tokenType: response.data.token_type,
registrationStatus: response.data.registration_status,
nextStep: response.data.next_step
},
redirectTo: "/authpengelola/completingcompanyprofile"
});
} else {
return json<VerifyOTPActionData>(
{
errors: {
otp:
response.meta.message ||
"Kode OTP tidak valid atau sudah kedaluwarsa"
}
},
{ status: 401 }
);
}
} catch (error: any) {
console.error("Verify OTP error:", error);
// Handle specific API errors
if (error.response?.data?.meta?.message) {
return json<VerifyOTPActionData>(
{
errors: { otp: error.response.data.meta.message }
},
{ status: error.response.status || 500 }
);
}
return json<VerifyOTPActionData>(
{
errors: { general: "Gagal memverifikasi OTP. Silakan coba lagi." }
},
{ status: 500 }
);
}
return json<VerifyOTPActionData>(
{
errors: { otp: "Kode OTP tidak valid atau sudah kedaluwarsa" }
},
{ status: 401 }
);
}
return json<VerifyOTPActionData>(
@ -289,10 +361,12 @@ export default function VerifyOTPToRegister() {
)}
{/* Error Alert */}
{actionData?.errors?.otp && (
{(actionData?.errors?.otp || actionData?.errors?.general) && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{actionData.errors.otp}</AlertDescription>
<AlertDescription>
{actionData.errors.otp || actionData.errors.general}
</AlertDescription>
</Alert>
)}
@ -407,18 +481,19 @@ export default function VerifyOTPToRegister() {
</CardContent>
</Card>
{/* Demo Info */}
<Card className="border border-blue-200 bg-blue-50/50 backdrop-blur-sm">
{/* Help Card */}
<Card className="border border-gray-200 bg-white/60 backdrop-blur-sm">
<CardContent className="p-4">
<div className="text-center">
<p className="text-sm font-medium text-blue-800 mb-2">Demo OTP:</p>
<div className="text-xs text-blue-700 space-y-1">
<p>
Gunakan kode:{" "}
<span className="font-mono font-bold text-lg">1234</span>
</p>
<p>Atau tunggu countdown habis untuk test resend</p>
</div>
<p className="text-sm text-gray-600 mb-2">Mengalami kesulitan?</p>
<a
href="https://wa.me/6281234567890?text=Halo%20saya%20butuh%20bantuan%20verifikasi%20OTP"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-green-600 hover:text-green-800 font-medium"
>
Hubungi Customer Support
</a>
</div>
</CardContent>
</Card>

View File

@ -1,21 +1,35 @@
import { json, redirect, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
import { useState, useEffect } from "react";
import {
json,
redirect,
type ActionFunctionArgs,
type LoaderFunctionArgs
} from "@remix-run/node";
import {
Form,
useActionData,
useLoaderData,
useNavigation,
Link
} from "@remix-run/react";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Progress } from "~/components/ui/progress";
import {
Clock,
CheckCircle,
Phone,
Mail,
MessageSquare,
RefreshCw,
FileText,
Shield,
Loader2,
AlertCircle,
Users
UserCheck,
ArrowLeft,
ArrowRight,
MessageCircle,
FileCheck
} from "lucide-react";
import pengelolaAuthService from "~/services/auth/pengelola.service";
import { getUserSession, createUserSession } from "~/sessions.server";
// Progress Indicator Component
const ProgressIndicator = ({ currentStep = 4, totalSteps = 5 }) => {
@ -56,84 +70,180 @@ const ProgressIndicator = ({ currentStep = 4, totalSteps = 5 }) => {
);
};
// Interface
// Interfaces
interface LoaderData {
phone: string;
submittedAt: string;
estimatedApprovalTime: string; // "1-3 hari kerja"
applicationId: string;
userSession: any;
lastChecked: string;
}
interface CheckApprovalActionData {
success?: boolean;
approved?: boolean;
message?: string;
errors?: {
general?: string;
};
}
export const loader = async ({
request
}: LoaderFunctionArgs): Promise<Response> => {
const url = new URL(request.url);
const phone = url.searchParams.get("phone");
const userSession = await getUserSession(request);
if (!phone) {
return redirect("/authpengelola/requestotpforregister");
// Check if user is authenticated and has pengelola role
if (!userSession || userSession.role !== "pengelola") {
return redirect("/authpengelola");
}
// Check if user should be on this step
if (userSession.registrationStatus !== "awaiting_approval") {
// Redirect based on current status
switch (userSession.registrationStatus) {
case "uncomplete":
return redirect("/authpengelola/completingcompanyprofile");
case "approved":
return redirect("/authpengelola/createanewpin");
case "complete":
return redirect("/pengelola/dashboard");
default:
break;
}
}
// Simulasi data - dalam implementasi nyata, ambil dari database
return json<LoaderData>({
phone,
submittedAt: new Date().toISOString(),
estimatedApprovalTime: "1-3 hari kerja",
applicationId: "WF" + Date.now().toString().slice(-6)
userSession,
lastChecked: new Date().toISOString()
});
};
export default function WaitingApprovalFromAdministrator() {
const { phone, submittedAt, estimatedApprovalTime, applicationId } =
useLoaderData<LoaderData>();
const [timeElapsed, setTimeElapsed] = useState(0);
export const action = async ({
request
}: ActionFunctionArgs): Promise<Response> => {
const userSession = await getUserSession(request);
// Timer untuk menunjukkan berapa lama sudah menunggu
if (!userSession || userSession.role !== "pengelola") {
return redirect("/authpengelola");
}
try {
// Call API untuk check approval status
const response = await pengelolaAuthService.checkApproval();
if (response.meta.status === 200 && response.data) {
if (response.data.registration_status === "approved") {
// User sudah di-approve, update session dan redirect
return createUserSession({
request,
sessionData: {
...userSession,
...(response.data.access_token && {
accessToken: response.data.access_token
}),
...(response.data.refresh_token && {
refreshToken: response.data.refresh_token
}),
...(response.data.session_id && {
sessionId: response.data.session_id
}),
tokenType: response.data.token_type,
registrationStatus: response.data.registration_status,
nextStep: response.data.next_step
},
redirectTo: "/authpengelola/createanewpin"
});
} else {
// Masih awaiting approval
return json<CheckApprovalActionData>({
success: true,
approved: false,
message:
response.data.message || "Masih menunggu persetujuan administrator"
});
}
} else {
return json<CheckApprovalActionData>(
{
errors: {
general:
response.meta.message || "Gagal mengecek status persetujuan"
}
},
{ status: 400 }
);
}
} catch (error: any) {
console.error("Check approval error:", error);
// Handle specific API errors
if (error.response?.data?.meta?.message) {
return json<CheckApprovalActionData>(
{
errors: { general: error.response.data.meta.message }
},
{ status: error.response.status || 500 }
);
}
return json<CheckApprovalActionData>(
{
errors: {
general: "Gagal mengecek status persetujuan. Silakan coba lagi."
}
},
{ status: 500 }
);
}
};
export default function WaitingApprovalFromAdministrator() {
const { userSession, lastChecked } = useLoaderData<LoaderData>();
const actionData = useActionData<CheckApprovalActionData>();
const navigation = useNavigation();
const [timeWaiting, setTimeWaiting] = useState<string>("");
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
const isSubmitting = navigation.state === "submitting";
// Calculate time waiting
useEffect(() => {
const interval = setInterval(() => {
const now = new Date().getTime();
const submitted = new Date(submittedAt).getTime();
const elapsed = Math.floor((now - submitted) / 1000 / 60); // minutes
setTimeElapsed(elapsed);
}, 60000); // Update setiap menit
const updateTimeWaiting = () => {
const now = new Date();
const submitted = new Date(lastChecked);
const diffMs = now.getTime() - submitted.getTime();
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
setTimeWaiting(`${hours} jam ${minutes} menit`);
} else {
setTimeWaiting(`${minutes} menit`);
}
};
updateTimeWaiting();
const interval = setInterval(updateTimeWaiting, 60000); // Update every minute
return () => clearInterval(interval);
}, [submittedAt]);
}, [lastChecked]);
// Format phone display
const formatPhone = (phoneNumber: string) => {
if (phoneNumber.length <= 2) return phoneNumber;
if (phoneNumber.length <= 5)
return `${phoneNumber.substring(0, 2)} ${phoneNumber.substring(2)}`;
if (phoneNumber.length <= 9)
return `${phoneNumber.substring(0, 2)} ${phoneNumber.substring(
2,
5
)} ${phoneNumber.substring(5)}`;
return `${phoneNumber.substring(0, 2)} ${phoneNumber.substring(
2,
5
)} ${phoneNumber.substring(5, 9)} ${phoneNumber.substring(9)}`;
};
// Auto refresh every 30 seconds
useEffect(() => {
if (!autoRefresh) return;
// Format elapsed time
const formatElapsedTime = (minutes: number) => {
if (minutes < 60) return `${minutes} menit yang lalu`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} jam yang lalu`;
const days = Math.floor(hours / 24);
return `${days} hari yang lalu`;
};
const interval = setInterval(() => {
// Trigger form submission to check approval
const form = document.getElementById(
"check-approval-form"
) as HTMLFormElement;
if (form && !isSubmitting) {
form.requestSubmit();
}
}, 30000); // 30 seconds
// Simulasi status checker - dalam implementasi nyata, polling ke server
const checkStatus = () => {
// Untuk demo, redirect ke step berikutnya setelah beberapa detik
setTimeout(() => {
window.location.href = `/authpengelola/createanewpin?phone=${encodeURIComponent(
phone
)}`;
}, 2000);
};
return () => clearInterval(interval);
}, [autoRefresh, isSubmitting]);
return (
<div className="space-y-6">
@ -143,219 +253,202 @@ export default function WaitingApprovalFromAdministrator() {
{/* Main Card */}
<Card className="border-0 shadow-2xl bg-white/80 backdrop-blur-sm">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 p-3 bg-gradient-to-br from-yellow-100 to-orange-100 rounded-full w-fit">
<Clock className="h-8 w-8 text-yellow-600 animate-pulse" />
<div className="mx-auto mb-4 p-3 bg-gradient-to-br from-orange-100 to-yellow-100 rounded-full w-fit">
<Clock className="h-8 w-8 text-orange-600" />
</div>
<h1 className="text-2xl font-bold text-gray-900">
Menunggu Persetujuan Administrator
Menunggu Persetujuan
</h1>
<p className="text-muted-foreground mt-2">
Aplikasi Anda sedang dalam proses verifikasi
Profil perusahaan Anda sedang ditinjau oleh administrator
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Status Info */}
<div className="text-center space-y-2">
<div className="inline-flex items-center space-x-2 px-4 py-2 bg-yellow-50 border border-yellow-200 rounded-full">
<div className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
<span className="text-sm font-medium text-yellow-800">
Status: Dalam Review
</span>
</div>
<p className="text-sm text-gray-600">
ID Aplikasi:{" "}
<span className="font-mono font-medium">{applicationId}</span>
</p>
</div>
{/* Status Alert */}
{actionData?.success && !actionData.approved && (
<Alert className="border-blue-200 bg-blue-50">
<Clock className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-blue-800">
{actionData.message}
</AlertDescription>
</Alert>
)}
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">
Progress Verifikasi
</span>
<span className="text-sm text-gray-500">75%</span>
</div>
<Progress value={75} className="h-2" />
<p className="text-xs text-gray-500">
Estimasi waktu: {estimatedApprovalTime}
</p>
</div>
{/* Error Alert */}
{actionData?.errors?.general && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{actionData.errors.general}</AlertDescription>
</Alert>
)}
{/* Submitted Info */}
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center space-x-3 mb-3">
<CheckCircle className="h-5 w-5 text-green-600" />
<span className="font-medium text-green-800">
Aplikasi Berhasil Dikirim
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<p className="text-green-700">
<strong>Nomor WhatsApp:</strong> {formatPhone(phone)}
</p>
</div>
<div>
<p className="text-green-700">
<strong>Waktu Kirim:</strong> {formatElapsedTime(timeElapsed)}
</p>
</div>
</div>
</div>
{/* Next Steps */}
{/* Waiting Status */}
<div className="space-y-4">
<h3 className="font-medium text-gray-900 flex items-center">
<FileText className="h-5 w-5 mr-2" />
Proses Selanjutnya
</h3>
{/* Progress Animation */}
<div className="relative">
<div className="flex items-center justify-center space-x-8 py-8">
<div className="flex flex-col items-center space-y-2">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="h-6 w-6 text-green-600" />
</div>
<span className="text-sm font-medium text-green-600">
Data Dikirim
</span>
</div>
<div className="space-y-3">
<div className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
<div className="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center text-xs font-medium text-blue-600 mt-0.5">
1
<div className="flex-1 h-0.5 bg-orange-200 relative overflow-hidden">
<div className="absolute inset-0 bg-orange-400 animate-pulse"></div>
</div>
<div>
<p className="text-sm font-medium text-gray-900">
Verifikasi Data Perusahaan
</p>
<p className="text-xs text-gray-600">
Administrator akan memverifikasi informasi yang Anda berikan
</p>
</div>
</div>
<div className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
<div className="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center text-xs font-medium text-blue-600 mt-0.5">
2
<div className="flex flex-col items-center space-y-2">
<div className="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center animate-pulse">
<UserCheck className="h-6 w-6 text-orange-600" />
</div>
<span className="text-sm font-medium text-orange-600">
Verifikasi Admin
</span>
</div>
<div>
<p className="text-sm font-medium text-gray-900">
Pengecekan Dokumen
</p>
<p className="text-xs text-gray-600">
Validasi legalitas dan kredibilitas perusahaan
</p>
</div>
</div>
<div className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
<div className="w-6 h-6 rounded-full bg-green-100 flex items-center justify-center text-xs font-medium text-green-600 mt-0.5">
3
</div>
<div>
<p className="text-sm font-medium text-gray-900">
Persetujuan & Aktivasi
</p>
<p className="text-xs text-gray-600">
Akun akan diaktivasi dan Anda bisa membuat PIN
</p>
<div className="flex-1 h-0.5 bg-gray-200"></div>
<div className="flex flex-col items-center space-y-2">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
<CheckCircle className="h-6 w-6 text-gray-400" />
</div>
<span className="text-sm font-medium text-gray-400">
Approved
</span>
</div>
</div>
</div>
</div>
{/* Contact Info */}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start space-x-3">
<Users className="h-5 w-5 text-blue-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-800 mb-2">
Butuh Bantuan atau Informasi?
</p>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Phone className="h-4 w-4 text-blue-600" />
<a
href="tel:+6281234567890"
className="text-sm text-blue-700 hover:text-blue-900 font-medium"
>
+62 812-3456-7890
</a>
{/* Time Waiting */}
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600">Telah menunggu:</p>
<p className="text-lg font-semibold text-gray-900">
{timeWaiting}
</p>
</div>
{/* Information Cards */}
<div className="grid gap-4">
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start space-x-3">
<FileCheck className="h-5 w-5 text-blue-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-800">
Proses Verifikasi
</p>
<p className="text-xs text-blue-700 mt-1">
Administrator sedang memverifikasi dokumen dan informasi
perusahaan yang Anda berikan. Proses ini biasanya memakan
waktu 1-24 jam.
</p>
</div>
<div className="flex items-center space-x-2">
<Mail className="h-4 w-4 text-blue-600" />
<a
href="mailto:admin@wasteflow.com"
className="text-sm text-blue-700 hover:text-blue-900 font-medium"
>
admin@wasteflow.com
</a>
</div>
<div className="flex items-center space-x-2">
<MessageSquare className="h-4 w-4 text-blue-600" />
<a
href={`https://wa.me/6281234567890?text=Halo%20saya%20ingin%20bertanya%20tentang%20aplikasi%20${applicationId}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-700 hover:text-blue-900 font-medium"
>
WhatsApp Admin
</a>
</div>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-start space-x-3">
<AlertCircle className="h-5 w-5 text-yellow-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-yellow-800">
Yang Diverifikasi
</p>
<div className="text-xs text-yellow-700 mt-1 space-y-1">
<p> Kebenaran informasi perusahaan</p>
<p> Validitas dokumen NPWP/Tax ID</p>
<p> Kesesuaian bidang usaha</p>
<p> Kelengkapan data kontak</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="space-y-3">
{/* Check Status Form */}
<Form method="post" id="check-approval-form">
<Button
onClick={checkStatus}
className="w-full h-12 bg-gradient-to-r from-green-600 to-blue-600 hover:from-green-700 hover:to-blue-700 text-white shadow-lg"
type="submit"
variant="outline"
className="w-full h-12 border-2 border-orange-200 hover:bg-orange-50 text-orange-700"
disabled={isSubmitting}
>
<RefreshCw className="mr-2 h-5 w-5" />
Cek Status Persetujuan
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Mengecek Status...
</>
) : (
<>
<RefreshCw className="mr-2 h-5 w-5" />
Cek Status Persetujuan
</>
)}
</Button>
</Form>
<Link to="/authpengelola">
<Button variant="outline" className="w-full h-12">
Kembali ke Halaman Utama
</Button>
</Link>
{/* Auto Refresh Toggle */}
<div className="flex items-center justify-center space-x-3 text-sm text-gray-600">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded border-gray-300 text-orange-600 focus:ring-orange-500"
/>
<span>Auto-refresh setiap 30 detik</span>
</label>
</div>
{/* Important Note */}
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start space-x-3">
<AlertCircle className="h-5 w-5 text-amber-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800">
Penting untuk Diingat
</p>
<ul className="text-xs text-amber-700 mt-1 space-y-1">
<li>
Jangan tutup aplikasi ini, bookmark halaman untuk akses
mudah
</li>
<li>
Anda akan mendapat notifikasi WhatsApp saat disetujui
</li>
<li>
Proses verifikasi dilakukan pada hari kerja (Senin-Jumat)
</li>
<li>
Pastikan nomor WhatsApp aktif untuk menerima notifikasi
</li>
</ul>
</div>
{/* Contact Support */}
<div className="text-center space-y-3">
<p className="text-sm text-gray-600">
Sudah lebih dari 24 jam? Hubungi administrator
</p>
<div className="flex flex-col space-y-2">
<a
href="https://wa.me/6281234567890?text=Halo%20saya%20butuh%20bantuan%20verifikasi%20akun%20pengelola"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center text-sm text-green-600 hover:text-green-800 font-medium"
>
<MessageCircle className="mr-1 h-4 w-4" />
WhatsApp: +62 812-3456-7890
</a>
<a
href="mailto:admin@wasteflow.com?subject=Bantuan%20Verifikasi%20Akun%20Pengelola"
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
>
Email: admin@wasteflow.com
</a>
</div>
</div>
{/* Back Link */}
<div className="text-center border-t pt-4">
<Link
to="/authpengelola/completingcompanyprofile"
className="inline-flex items-center text-sm text-gray-600 hover:text-green-600 transition-colors"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Edit profil perusahaan
</Link>
</div>
</CardContent>
</Card>
{/* Demo Card */}
<Card className="border border-green-200 bg-green-50/50 backdrop-blur-sm">
{/* Tips Card */}
<Card className="border border-gray-200 bg-white/60 backdrop-blur-sm">
<CardContent className="p-4">
<div className="text-center">
<p className="text-sm font-medium text-green-800 mb-2">
Demo Mode:
<div className="text-center space-y-2">
<p className="text-sm font-medium text-gray-700">
💡 Tips: Pastikan informasi yang diberikan akurat
</p>
<p className="text-xs text-green-700">
Klik "Cek Status Persetujuan" untuk simulasi approval dan lanjut
ke step terakhir
<p className="text-xs text-gray-600">
Jika ada kesalahan data, admin akan menghubungi Anda untuk revisi
</p>
</div>
</CardContent>