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 { 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 { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
@ -13,6 +13,16 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from "~/components/ui/alert-dialog";
import { import {
Menu, Menu,
Search, Search,
@ -25,7 +35,8 @@ import {
ChevronDown, ChevronDown,
MoreHorizontal, MoreHorizontal,
PanelLeftClose, PanelLeftClose,
PanelLeft PanelLeft,
Loader2
} from "lucide-react"; } from "lucide-react";
import { SessionData } from "~/sessions.server"; // Import SessionData type import { SessionData } from "~/sessions.server"; // Import SessionData type
@ -42,7 +53,9 @@ export function PengelolaHeader({
isMobile, isMobile,
user // Add user prop user // Add user prop
}: PengelolaHeaderProps) { }: 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 // Get user initials for avatar fallback
const getUserInitials = (name: string) => { const getUserInitials = (name: string) => {
@ -55,204 +68,214 @@ export function PengelolaHeader({
}; };
return ( 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"> <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">
{/* Mobile header */} <div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
<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"> {/* Mobile header */}
{/* Hamburger menu button */} <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">
<Button {/* Hamburger menu button */}
variant="outline" <Button
size="icon" variant="outline"
onClick={onMenuClick} size="icon"
className="hover:bg-gray-100 dark:hover:bg-gray-700 border-gray-300 dark:border-gray-600 dark:bg-gray-800" 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" /> {isMobile ? (
) : sidebarCollapsed ? ( <Menu className="h-5 w-5" />
<PanelLeft className="h-5 w-5" /> ) : sidebarCollapsed ? (
) : ( <PanelLeft className="h-5 w-5" />
<PanelLeftClose className="h-5 w-5" /> ) : (
)} <PanelLeftClose className="h-5 w-5" />
</Button> )}
</Button>
{/* Mobile logo */} {/* Mobile logo */}
<div className="lg:hidden"> <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"> <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 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>
</div> </div>
</div> </div>
</div> */}
</div>
{/* Desktop header actions */} {/* Mobile more menu */}
<div className="hidden items-center justify-between w-full gap-4 px-5 py-4 lg:flex lg:justify-end lg:px-0"> <Sheet>
<div className="flex items-center gap-3"> <SheetTrigger asChild>
{/* Theme toggle */} <Button variant="ghost" size="icon" className="lg:hidden">
<ModeToggle /> <MoreHorizontal className="h-5 w-5" />
{/* 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> </Button>
</DropdownMenuTrigger> </SheetTrigger>
<DropdownMenuContent <SheetContent side="right" className="w-80">
align="end" <div className="mt-6 space-y-4">
className="w-80 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700" <Button className="w-full" variant="outline">
> <Bell className="mr-2 h-4 w-4" />
<div className="p-4">
<h4 className="font-semibold mb-2 flex items-center justify-between text-gray-900 dark:text-gray-100">
Notifications Notifications
<Badge variant="secondary" className="text-xs"> <Badge variant="destructive" className="ml-auto">
3 new 3
</Badge> </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> </Button>
<ModeToggle />
</div> </div>
</DropdownMenuContent> </SheetContent>
</DropdownMenu> </Sheet>
</div>
{/* User menu */} {/* Desktop header actions */}
<DropdownMenu> <div className="hidden items-center justify-between w-full gap-4 px-5 py-4 lg:flex lg:justify-end lg:px-0">
<DropdownMenuTrigger asChild> <div className="flex items-center gap-3">
<Button {/* Theme toggle */}
variant="ghost" <ModeToggle />
className="h-auto p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
{/* 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"> <div className="p-4">
<Avatar className="h-8 w-8"> <h4 className="font-semibold mb-2 flex items-center justify-between text-gray-900 dark:text-gray-100">
<AvatarImage Notifications
src={""} <Badge variant="secondary" className="text-xs">
alt={"User"} 3 new
/> </Badge>
<AvatarFallback className="bg-blue-600 text-white"> </h4>
{getUserInitials("User")} <div className="space-y-2">
</AvatarFallback> <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">
</Avatar> <p className="font-medium text-gray-900 dark:text-gray-100">
<div className="hidden sm:block text-left"> New user registered
<div className="text-sm font-medium text-gray-900 dark:text-gray-100"> </p>
{user.sessionId || "User"} <p className="text-xs text-gray-500 dark:text-gray-300 mt-1">
2 minutes ago
</p>
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-300"> <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">
{user.role || "Pengelola"} <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>
</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> </div>
</Button> </DropdownMenuContent>
</DropdownMenuTrigger> </DropdownMenu>
<DropdownMenuContent
align="end" {/* User menu */}
className="w-56 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700" <DropdownMenu>
> <DropdownMenuTrigger asChild>
<div className="px-2 py-1.5 text-sm text-gray-700 dark:text-gray-200"> <Button
<div className="font-medium">{user.phone || "User"}</div> variant="ghost"
<div className="text-xs text-gray-500 dark:text-gray-300"> className="h-auto p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
{user.email || "user@example.com"} >
<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>
</div> <DropdownMenuSeparator />
<DropdownMenuSeparator /> <DropdownMenuItem className="cursor-pointer">
<DropdownMenuItem className="cursor-pointer"> <User className="mr-2 h-4 w-4" />
<User className="mr-2 h-4 w-4" /> Profile
Profile </DropdownMenuItem>
</DropdownMenuItem> <DropdownMenuItem className="cursor-pointer">
<DropdownMenuItem className="cursor-pointer"> <Settings className="mr-2 h-4 w-4" />
<Settings className="mr-2 h-4 w-4" /> Settings
Settings </DropdownMenuItem>
</DropdownMenuItem> <DropdownMenuSeparator />
<DropdownMenuSeparator /> <DropdownMenuItem
<DropdownMenuItem asChild> className="cursor-pointer text-red-600 dark:text-red-400"
<Form action="/logout" method="post" className="w-full"> onClick={() => setShowLogoutDialog(true)}
<button >
type="submit" <LogOut className="mr-2 h-4 w-4" />
className="flex w-full items-center text-red-600 dark:text-red-400 cursor-pointer" Sign out
> </DropdownMenuItem>
<LogOut className="mr-2 h-4 w-4" /> </DropdownMenuContent>
Sign out </DropdownMenu>
</button> </div>
</Form>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </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, Linkedin,
Instagram, Instagram,
Recycle, Recycle,
ArrowUp ArrowUp,
ArrowRight,
User
} from "lucide-react"; } from "lucide-react";
import { ModeToggle } from "~/components/ui/dark-mode-toggle"; import { ModeToggle } from "~/components/ui/dark-mode-toggle";
import { getUserSession } from "~/sessions.server";
export const meta: MetaFunction = () => { export const meta: MetaFunction = () => {
return [ return [
@ -44,10 +47,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
{ name: "Instagram", icon: "instagram", href: "#" } { name: "Instagram", icon: "instagram", href: "#" }
]; ];
// Get real user session
const userSession = await getUserSession(request);
const authData = { const authData = {
isAuthenticated: false, isAuthenticated: !!userSession,
isRegistrationComplete: false, isRegistrationComplete: userSession?.registrationStatus === "complete",
userRole: null as string | null userRole: userSession?.role || null,
userPhone: userSession?.phone || null,
userEmail: userSession?.email || null
}; };
return json({ return json({
@ -65,7 +73,13 @@ export default function LandingLayout() {
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [showBackToTop, setShowBackToTop] = useState(false); const [showBackToTop, setShowBackToTop] = useState(false);
const { isAuthenticated, isRegistrationComplete, userRole } = authData; const {
isAuthenticated,
isRegistrationComplete,
userRole,
userPhone,
userEmail
} = authData;
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
@ -100,39 +114,58 @@ export default function LandingLayout() {
} }
}; };
const getRedirectPath = () => { const getDashboardPath = () => {
if (userRole === "administrator") { if (userRole === "administrator") {
return "/sys-rijig-adminpanel/dashboard"; return "/sys-rijig-adminpanel/dashboard";
} }
return "/pengelola/dashboard"; return "/pengelola/dashboard";
}; };
const handleGetStarted = () => { const handleMainAction = () => {
if (isAuthenticated && isRegistrationComplete) { if (isAuthenticated && isRegistrationComplete) {
const dashboardPath = // User sudah login dan registrasi complete → Go to Dashboard
userRole === "administrator" navigate(getDashboardPath());
? "/sys-rijig-adminpanel/dashboard"
: "/pengelola/dashboard";
navigate(dashboardPath);
} else if (isAuthenticated && !isRegistrationComplete) { } else if (isAuthenticated && !isRegistrationComplete) {
const redirectPath = getRedirectPath(); // User sudah login tapi registrasi belum complete → Redirect ke step berikutnya
navigate(redirectPath); navigate(getDashboardPath());
} else { } else {
navigate("/pengelola/register"); // User belum login → Get Started (redirect ke authpengelola)
navigate("/authpengelola");
} }
}; };
const handleAdminLogin = () => {
navigate("/sys-rijig-adminpanel/login");
};
const getButtonText = () => { const getButtonText = () => {
if (isAuthenticated && isRegistrationComplete) { if (isAuthenticated && isRegistrationComplete) {
return "Go to Dashboard"; return "Go to Dashboard";
} else if (isAuthenticated && !isRegistrationComplete) {
return "Continue Setup";
} }
return "Get Started"; 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 ( return (
<div className="min-h-screen"> <div className="min-h-screen">
{/* Header dengan glassmorphism effect */} {/* Header dengan glassmorphism effect */}
@ -213,6 +246,26 @@ export default function LandingLayout() {
{/* Right Side Actions */} {/* Right Side Actions */}
<div className="hidden lg:flex items-center space-x-4"> <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 <div
className={`transition-all duration-700 ${ className={`transition-all duration-700 ${
isScrolled isScrolled
@ -222,8 +275,9 @@ export default function LandingLayout() {
> >
<ModeToggle /> <ModeToggle />
</div> </div>
<Button <Button
onClick={handleGetStarted} onClick={handleMainAction}
className={`transition-all duration-700 shadow-lg hover:scale-105 rounded-lg ${ className={`transition-all duration-700 shadow-lg hover:scale-105 rounded-lg ${
isScrolled isScrolled
? "bg-green-600 hover:bg-green-700 text-white shadow-green-600/20" ? "bg-green-600 hover:bg-green-700 text-white shadow-green-600/20"
@ -231,6 +285,7 @@ export default function LandingLayout() {
}`} }`}
> >
{getButtonText()} {getButtonText()}
{getButtonIcon()}
</Button> </Button>
</div> </div>
</div> </div>
@ -251,6 +306,35 @@ export default function LandingLayout() {
}`} }`}
> >
<nav className="space-y-4 p-4"> <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) => ( {navigationItems.map((item) => (
<button <button
key={item.href} key={item.href}
@ -285,7 +369,7 @@ export default function LandingLayout() {
<ModeToggle /> <ModeToggle />
</div> </div>
<Button <Button
onClick={handleGetStarted} onClick={handleMainAction}
size="sm" size="sm"
className={`hover:scale-105 transition-all duration-700 rounded-lg ${ className={`hover:scale-105 transition-all duration-700 rounded-lg ${
isScrolled isScrolled
@ -294,6 +378,7 @@ export default function LandingLayout() {
}`} }`}
> >
{getButtonText()} {getButtonText()}
{getButtonIcon()}
</Button> </Button>
</div> </div>
</nav> </nav>
@ -361,14 +446,6 @@ export default function LandingLayout() {
> >
<Facebook className="h-5 w-5" /> <Facebook className="h-5 w-5" />
</Button> </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 <Button
variant="ghost" variant="ghost"
size="icon" 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, MapPin,
User, User,
FileText, FileText,
Phone Phone,
Upload,
Image as ImageIcon
} from "lucide-react"; } 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 // Progress Indicator Component
const ProgressIndicator = ({ currentStep = 3, totalSteps = 5 }) => { const ProgressIndicator = ({ currentStep = 3, totalSteps = 5 }) => {
@ -79,20 +84,20 @@ const ProgressIndicator = ({ currentStep = 3, totalSteps = 5 }) => {
// Interfaces // Interfaces
interface LoaderData { interface LoaderData {
phone: string; userSession: any;
} }
interface CompanyProfileActionData { interface CompanyProfileActionData {
errors?: { errors?: {
companyName?: string; companyname?: string;
ownerName?: string; companyaddress?: string;
companyType?: string; companyphone?: string;
address?: string; companyemail?: string;
city?: string; companywebsite?: string;
postalCode?: string; taxid?: string;
businessType?: string; foundeddate?: string;
employeeCount?: string; companytype?: string;
serviceArea?: string; companydescription?: string;
general?: string; general?: string;
}; };
success?: boolean; success?: boolean;
@ -101,98 +106,177 @@ interface CompanyProfileActionData {
export const loader = async ({ export const loader = async ({
request request
}: LoaderFunctionArgs): Promise<Response> => { }: LoaderFunctionArgs): Promise<Response> => {
const url = new URL(request.url); const userSession = await getUserSession(request);
const phone = url.searchParams.get("phone");
if (!phone) { // Check if user is authenticated and has pengelola role
return redirect("/authpengelola/requestotpforregister"); 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 ({ export const action = async ({
request request
}: ActionFunctionArgs): Promise<Response> => { }: ActionFunctionArgs): Promise<Response> => {
const formData = await request.formData(); const userSession = await getUserSession(request);
const phone = formData.get("phone") as string;
// 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 = { const companyData = {
companyName: formData.get("companyName") as string, companyname: formData.get("companyname") as string,
ownerName: formData.get("ownerName") as string, companyaddress: formData.get("companyaddress") as string,
companyType: formData.get("companyType") as string, companyphone: formData.get("companyphone") as string,
address: formData.get("address") as string, companyemail: formData.get("companyemail") as string,
city: formData.get("city") as string, companywebsite: formData.get("companywebsite") as string,
postalCode: formData.get("postalCode") as string, taxid: formData.get("taxid") as string,
businessType: formData.get("businessType") as string, foundeddate: formData.get("foundeddate") as string,
employeeCount: formData.get("employeeCount") as string, companytype: formData.get("companytype") as string,
serviceArea: formData.get("serviceArea") as string, companydescription: formData.get("companydescription") as string,
description: formData.get("description") as string company_logo: formData.get("company_logo") as File | null
}; };
// Validation // Validation
const errors: { [key: string]: string } = {}; const errors: { [key: string]: string } = {};
if (!companyData.companyName?.trim()) { if (!companyData.companyname?.trim()) {
errors.companyName = "Nama perusahaan wajib diisi"; errors.companyname = "Nama perusahaan wajib diisi";
} }
if (!companyData.ownerName?.trim()) { if (!companyData.companyaddress?.trim()) {
errors.ownerName = "Nama pemilik/direktur wajib diisi"; errors.companyaddress = "Alamat perusahaan wajib diisi";
} }
if (!companyData.companyType) { if (!companyData.companyphone?.trim()) {
errors.companyType = "Jenis badan usaha wajib dipilih"; 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()) { if (!companyData.companyemail?.trim()) {
errors.address = "Alamat lengkap wajib diisi"; errors.companyemail = "Email perusahaan wajib diisi";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(companyData.companyemail)) {
errors.companyemail = "Format email tidak valid";
} }
if (!companyData.city?.trim()) { if (!companyData.companywebsite?.trim()) {
errors.city = "Kota wajib diisi"; 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()) { if (!companyData.taxid?.trim()) {
errors.postalCode = "Kode pos wajib diisi"; errors.taxid = "NPWP/Tax ID wajib diisi";
} else if (!/^\d{5}$/.test(companyData.postalCode)) {
errors.postalCode = "Kode pos harus 5 digit angka";
} }
if (!companyData.businessType) { if (!companyData.foundeddate?.trim()) {
errors.businessType = "Jenis usaha wajib dipilih"; 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) { if (!companyData.companytype?.trim()) {
errors.employeeCount = "Jumlah karyawan wajib dipilih"; errors.companytype = "Jenis perusahaan wajib dipilih";
} }
if (!companyData.serviceArea?.trim()) { if (!companyData.companydescription?.trim()) {
errors.serviceArea = "Area layanan wajib diisi"; errors.companydescription = "Deskripsi perusahaan wajib diisi";
} }
if (Object.keys(errors).length > 0) { if (Object.keys(errors).length > 0) {
return json<CompanyProfileActionData>({ errors }, { status: 400 }); return json<CompanyProfileActionData>({ errors }, { status: 400 });
} }
// Simulasi menyimpan data - dalam implementasi nyata, simpan ke database
try { 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 // Call API untuk create company profile
await new Promise((resolve) => setTimeout(resolve, 1000)); 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>( return json<CompanyProfileActionData>(
{ {
errors: { general: "Gagal menyimpan data. Silakan coba lagi." } errors: {
general: "Gagal menyimpan profil perusahaan. Silakan coba lagi."
}
}, },
{ status: 500 } { status: 500 }
); );
@ -200,12 +284,39 @@ export const action = async ({
}; };
export default function CompletingCompanyProfile() { export default function CompletingCompanyProfile() {
const { phone } = useLoaderData<LoaderData>(); const { userSession } = useLoaderData<LoaderData>();
const actionData = useActionData<CompanyProfileActionData>(); const actionData = useActionData<CompanyProfileActionData>();
const navigation = useNavigation(); const navigation = useNavigation();
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const isSubmitting = navigation.state === "submitting"; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Progress Indicator */} {/* Progress Indicator */}
@ -235,9 +346,11 @@ export default function CompletingCompanyProfile() {
)} )}
{/* Form */} {/* Form */}
<Form method="post" className="space-y-6"> <Form
<input type="hidden" name="phone" value={phone} /> method="post"
encType="multipart/form-data"
className="space-y-6"
>
{/* Company Information Section */} {/* Company Information Section */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center space-x-2 mb-4"> <div className="flex items-center space-x-2 mb-4">
@ -249,246 +362,214 @@ export default function CompletingCompanyProfile() {
{/* Company Name */} {/* Company Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="companyName">Nama Perusahaan *</Label> <Label htmlFor="companyname">Nama Perusahaan *</Label>
<Input <Input
id="companyName" id="companyname"
name="companyName" name="companyname"
type="text" type="text"
placeholder="PT/CV/Koperasi Nama Perusahaan" placeholder="PT/CV/Koperasi Nama Perusahaan"
className={ className={
actionData?.errors?.companyName ? "border-red-500" : "" actionData?.errors?.companyname ? "border-red-500" : ""
} }
required required
/> />
{actionData?.errors?.companyName && ( {actionData?.errors?.companyname && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
{actionData.errors.companyName} {actionData.errors.companyname}
</p> </p>
)} )}
</div> </div>
{/* Owner Name */} {/* Company Address */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ownerName">Nama Pemilik/Direktur *</Label> <Label htmlFor="companyaddress">Alamat Perusahaan *</Label>
<Input <Textarea
id="ownerName" id="companyaddress"
name="ownerName" name="companyaddress"
type="text" placeholder="Alamat lengkap perusahaan"
placeholder="Nama lengkap pemilik atau direktur"
className={ 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 required
/> />
{actionData?.errors?.ownerName && ( {actionData?.errors?.companyphone && (
<p className="text-sm text-red-600"> <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> </p>
)} )}
</div> </div>
{/* Company Type */} {/* Company Type */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="companyType">Jenis Badan Usaha *</Label> <Label htmlFor="companytype">Jenis Perusahaan *</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>
<Input <Input
id="serviceArea" id="companytype"
name="serviceArea" name="companytype"
type="text" type="text"
placeholder="Jakarta Utara, Bekasi, Tangerang, dll" placeholder="Waste recycle"
className={ className={
actionData?.errors?.serviceArea ? "border-red-500" : "" actionData?.errors?.companytype ? "border-red-500" : ""
} }
required required
/> />
{actionData?.errors?.serviceArea && ( {actionData?.errors?.companytype && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
{actionData.errors.serviceArea} {actionData.errors.companytype}
</p> </p>
)} )}
</div> </div>
{/* Description */} {/* Company Description */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Deskripsi Usaha (Opsional)</Label> <Label htmlFor="companydescription">
Deskripsi Perusahaan *
</Label>
<Textarea <Textarea
id="description" id="companydescription"
name="description" name="companydescription"
placeholder="Ceritakan lebih detail tentang usaha pengelolaan sampah Anda..." placeholder="Ceritakan tentang perusahaan Anda..."
rows={3} 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>
</div> </div>
@ -532,13 +613,11 @@ export default function CompletingCompanyProfile() {
{/* Back Link */} {/* Back Link */}
<div className="text-center"> <div className="text-center">
<Link <Link
to={`/authpengelola/verifyotptoregister?phone=${encodeURIComponent( to="/authpengelola"
phone
)}`}
className="inline-flex items-center text-sm text-gray-600 hover:text-green-600 transition-colors" className="inline-flex items-center text-sm text-gray-600 hover:text-green-600 transition-colors"
> >
<ArrowLeft className="mr-1 h-4 w-4" /> <ArrowLeft className="mr-1 h-4 w-4" />
Kembali ke verifikasi OTP Kembali ke halaman utama
</Link> </Link>
</div> </div>
</CardContent> </CardContent>

View File

@ -8,7 +8,8 @@ import {
Form, Form,
useActionData, useActionData,
useLoaderData, useLoaderData,
useNavigation useNavigation,
Link
} from "@remix-run/react"; } from "@remix-run/react";
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { Card, CardContent, CardHeader } from "~/components/ui/card"; 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 { Alert, AlertDescription } from "~/components/ui/alert";
import { import {
Shield, Shield,
CheckCircle, ArrowLeft,
ArrowRight,
AlertCircle, AlertCircle,
Loader2, Loader2,
Lock, CheckCircle,
Eye, Eye,
EyeOff, EyeOff,
Sparkles Lock,
Star
} from "lucide-react"; } 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 // Progress Indicator Component
const ProgressIndicator = ({ currentStep = 5, totalSteps = 5 }) => { 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> </div>
{stepNumber < totalSteps && ( {stepNumber < totalSteps && (
<div <div
@ -68,13 +78,12 @@ const ProgressIndicator = ({ currentStep = 5, totalSteps = 5 }) => {
// Interfaces // Interfaces
interface LoaderData { interface LoaderData {
phone: string; userSession: any;
approvedAt: string;
} }
interface CreatePINActionData { interface CreatePinActionData {
errors?: { errors?: {
pin?: string; userpin?: string;
confirmPin?: string; confirmPin?: string;
general?: string; general?: string;
}; };
@ -84,61 +93,106 @@ interface CreatePINActionData {
export const loader = async ({ export const loader = async ({
request request
}: LoaderFunctionArgs): Promise<Response> => { }: LoaderFunctionArgs): Promise<Response> => {
const url = new URL(request.url); const userSession = await getUserSession(request);
const phone = url.searchParams.get("phone");
if (!phone) { // Check if user is authenticated and has pengelola role
return redirect("/authpengelola/requestotpforregister"); if (!userSession || userSession.role !== "pengelola") {
return redirect("/authpengelola");
} }
return json<LoaderData>({ // Check if user should be on this step
phone, if (userSession.registrationStatus !== "approved") {
approvedAt: new Date().toISOString() // 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 ({ export const action = async ({
request request
}: ActionFunctionArgs): Promise<Response> => { }: ActionFunctionArgs): Promise<Response> => {
const userSession = await getUserSession(request);
if (!userSession || userSession.role !== "pengelola") {
return redirect("/authpengelola");
}
const formData = await request.formData(); const formData = await request.formData();
const phone = formData.get("phone") as string; const userpin = formData.get("userpin") as string;
const pin = formData.get("pin") as string;
const confirmPin = formData.get("confirmPin") as string; const confirmPin = formData.get("confirmPin") as string;
// Validation // Validation
const errors: { pin?: string; confirmPin?: string; general?: string } = {}; const errors: { [key: string]: string } = {};
if (!pin || pin.length !== 6) { if (!userpin) {
errors.pin = "PIN harus 6 digit"; errors.userpin = "PIN wajib diisi";
} else if (!/^\d{6}$/.test(pin)) { } else if (!validatePin(userpin)) {
errors.pin = "PIN hanya boleh berisi angka"; errors.userpin = "PIN harus 6 digit 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 (!confirmPin) { if (!confirmPin) {
errors.confirmPin = "Konfirmasi PIN wajib diisi"; errors.confirmPin = "Konfirmasi PIN wajib diisi";
} else if (pin !== confirmPin) { } else if (userpin !== confirmPin) {
errors.confirmPin = "PIN dan konfirmasi PIN tidak sama"; errors.confirmPin = "PIN dan konfirmasi PIN tidak sama";
} }
if (Object.keys(errors).length > 0) { 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 { try {
console.log("Creating PIN for phone:", phone); // Call API untuk create PIN
const response = await pengelolaAuthService.createPin({
userpin
});
// Simulasi delay API call if (response.meta.status === 200 && response.data) {
await new Promise((resolve) => setTimeout(resolve, 1500)); // 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 // Handle specific API errors
return redirect("/pengelola/dashboard"); if (error.response?.data?.meta?.message) {
} catch (error) { return json<CreatePinActionData>(
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." } errors: { general: "Gagal membuat PIN. Silakan coba lagi." }
}, },
@ -147,15 +201,15 @@ export const action = async ({
} }
}; };
export default function CreateANewPIN() { export default function CreateANewPin() {
const { phone, approvedAt } = useLoaderData<LoaderData>(); const { userSession } = useLoaderData<LoaderData>();
const actionData = useActionData<CreatePINActionData>(); const actionData = useActionData<CreatePinActionData>();
const navigation = useNavigation(); const navigation = useNavigation();
const [pin, setPin] = useState(["", "", "", "", "", ""]); const [pin, setPin] = useState(["", "", "", "", "", ""]);
const [confirmPin, setConfirmPin] = useState(["", "", "", "", "", ""]); const [confirmPin, setConfirmPin] = useState(["", "", "", "", "", ""]);
const [showPin, setShowPin] = useState(false); const [showPin, setShowPin] = useState(false);
const [pinStrength, setPinStrength] = useState(0); const [showConfirmPin, setShowConfirmPin] = useState(false);
const pinRefs = useRef<(HTMLInputElement | null)[]>([]); const pinRefs = useRef<(HTMLInputElement | null)[]>([]);
const confirmPinRefs = useRef<(HTMLInputElement | null)[]>([]); const confirmPinRefs = useRef<(HTMLInputElement | null)[]>([]);
@ -166,23 +220,20 @@ export default function CreateANewPIN() {
const handlePinChange = ( const handlePinChange = (
index: number, index: number,
value: string, value: string,
isConfirm: boolean = false isPinField: boolean = true
) => { ) => {
if (!/^\d*$/.test(value)) return; // Only allow digits if (!/^\d*$/.test(value)) return; // Only allow digits
const newPin = isConfirm ? [...confirmPin] : [...pin]; const currentPin = isPinField ? pin : confirmPin;
newPin[index] = value; const setCurrentPin = isPinField ? setPin : setConfirmPin;
const refs = isPinField ? pinRefs : confirmPinRefs;
if (isConfirm) { const newPin = [...currentPin];
setConfirmPin(newPin); newPin[index] = value;
} else { setCurrentPin(newPin);
setPin(newPin);
calculatePinStrength(newPin.join(""));
}
// Auto-focus next input // Auto-focus next input
if (value && index < 5) { if (value && index < 5) {
const refs = isConfirm ? confirmPinRefs : pinRefs;
refs.current[index + 1]?.focus(); refs.current[index + 1]?.focus();
} }
}; };
@ -191,115 +242,44 @@ export default function CreateANewPIN() {
const handleKeyDown = ( const handleKeyDown = (
index: number, index: number,
e: React.KeyboardEvent, e: React.KeyboardEvent,
isConfirm: boolean = false isPinField: boolean = true
) => { ) => {
if (e.key === "Backspace") { const currentPin = isPinField ? pin : confirmPin;
const currentPin = isConfirm ? confirmPin : pin; const refs = isPinField ? pinRefs : confirmPinRefs;
const refs = isConfirm ? confirmPinRefs : pinRefs;
if (!currentPin[index] && index > 0) { if (e.key === "Backspace" && !currentPin[index] && index > 0) {
refs.current[index - 1]?.focus(); 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 pinValue = pin.join("");
const calculatePinStrength = (pinValue: string) => { const confirmPinValue = confirmPin.join("");
if (pinValue.length < 6) { const isPinComplete = pinValue.length === 6 && confirmPinValue.length === 6;
setPinStrength(0); const isPinMatching = pinValue === confirmPinValue && pinValue.length === 6;
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("");
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Progress Indicator */} {/* Progress Indicator */}
<ProgressIndicator currentStep={5} totalSteps={5} /> <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 */} {/* Main Card */}
<Card className="border-0 shadow-2xl bg-white/80 backdrop-blur-sm"> <Card className="border-0 shadow-2xl bg-white/80 backdrop-blur-sm">
<CardHeader className="text-center pb-2"> <CardHeader className="text-center pb-2">
@ -310,7 +290,7 @@ export default function CreateANewPIN() {
Buat PIN Keamanan Buat PIN Keamanan
</h1> </h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Langkah terakhir untuk mengamankan akun Anda Langkah terakhir! Buat PIN 6 digit untuk mengamankan akun Anda
</p> </p>
</CardHeader> </CardHeader>
@ -325,137 +305,143 @@ export default function CreateANewPIN() {
{/* Form */} {/* Form */}
<Form method="post" className="space-y-6"> <Form method="post" className="space-y-6">
<input type="hidden" name="phone" value={phone} /> <input type="hidden" name="userpin" value={pinValue} />
<input type="hidden" name="pin" value={fullPin} /> <input type="hidden" name="confirmPin" value={confirmPinValue} />
<input type="hidden" name="confirmPin" value={fullConfirmPin} />
{/* PIN Input */} {/* PIN Input */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="space-y-3">
<Label className="text-base font-medium">PIN 6 Digit</Label> <div className="flex items-center justify-between">
<Button <Label className="text-base font-medium">
type="button" Masukkan PIN Baru
variant="ghost" </Label>
size="sm" <Button
onClick={() => setShowPin(!showPin)} type="button"
className="text-gray-500 hover:text-gray-700" variant="ghost"
> size="sm"
{showPin ? ( onClick={() => setShowPin(!showPin)}
<EyeOff className="h-4 w-4" /> className="h-8 px-2 text-gray-500 hover:text-gray-700"
) : ( >
<Eye className="h-4 w-4" /> {showPin ? (
)} <EyeOff className="h-4 w-4" />
</Button> ) : (
</div> <Eye className="h-4 w-4" />
)}
<div className="flex justify-center space-x-3"> </Button>
{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> </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> </div>
{/* Confirm PIN Input */} {/* PIN Requirements */}
<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 */}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg"> <div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Lock className="h-5 w-5 text-blue-600 mt-0.5" /> <Lock className="h-5 w-5 text-blue-600 mt-0.5" />
<div> <div>
<p className="text-sm font-medium text-blue-800 mb-2"> <p className="text-sm font-medium text-blue-800">
Tips PIN yang Aman: Syarat PIN Keamanan
</p> </p>
<ul className="text-xs text-blue-700 space-y-1"> <div className="text-xs text-blue-700 mt-1 space-y-1">
<li> Hindari angka berurutan (123456, 654321)</li> <p> Harus terdiri dari 6 digit angka</p>
<li> Jangan gunakan angka yang sama semua (111111)</li> <p>
<li> Hindari kombinasi mudah ditebak (000000, 123456)</li> Hindari urutan angka (123456) atau angka sama (111111)
<li> Gunakan kombinasi angka yang hanya Anda ketahui</li> </p>
</ul> <p> Jangan gunakan tanggal lahir atau nomor telepon</p>
<p>
PIN akan digunakan untuk verifikasi transaksi penting
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -464,38 +450,47 @@ export default function CreateANewPIN() {
<Button <Button
type="submit" 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" 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={ disabled={!isPinMatching || isSubmitting}
isSubmitting ||
fullPin.length !== 6 ||
fullConfirmPin.length !== 6 ||
fullPin !== fullConfirmPin ||
pinStrength < 50
}
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> <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 Selesaikan Registrasi
<ArrowRight className="ml-2 h-5 w-5" />
</> </>
)} )}
</Button> </Button>
</Form> </Form>
{/* Final Note */} {/* Back Link */}
<div className="p-4 bg-gradient-to-r from-green-50 to-blue-50 border border-green-200 rounded-lg"> <div className="text-center">
<div className="text-center"> <Link
<p className="text-sm font-medium text-gray-800 mb-1"> to="/authpengelola/waitingapprovalfromadministrator"
🎉 Hampir selesai! className="inline-flex items-center text-sm text-gray-600 hover:text-green-600 transition-colors"
</p> >
<p className="text-xs text-gray-600"> <ArrowLeft className="mr-1 h-4 w-4" />
Setelah membuat PIN, Anda akan langsung dapat mengakses Kembali ke status persetujuan
dashboard pengelola </Link>
</p> </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>
</div> </div>
</CardContent> </CardContent>

View File

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

View File

@ -27,8 +27,11 @@ import {
CheckCircle, CheckCircle,
Smartphone Smartphone
} from "lucide-react"; } 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 }) => { const ProgressIndicator = ({ currentStep = 2, totalSteps = 5 }) => {
return ( return (
<div className="flex items-center justify-center space-x-2 mb-8"> <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; const actionType = formData.get("_action") as string;
if (actionType === "resend") { if (actionType === "resend") {
// Simulasi resend OTP try {
console.log("Resending OTP to WhatsApp:", phone); // Call API untuk resend OTP
const response = await pengelolaAuthService.requestOtpRegister({
phone,
role_name: "pengelola"
});
return json<VerifyOTPActionData>({ if (response.meta.status === 200) {
success: true, return json<VerifyOTPActionData>({
message: "Kode OTP baru telah dikirim ke WhatsApp Anda", success: true,
otpSentAt: new Date().toISOString() 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") { if (actionType === "verify") {
// Validation // Validation
const errors: { otp?: string; general?: string } = {}; const errors: { otp?: string; general?: string } = {};
if (!otp || otp.length !== 4) { if (!otp) {
errors.otp = "Kode OTP harus 4 digit"; errors.otp = "Kode OTP wajib diisi";
} else if (!/^\d{4}$/.test(otp)) { } else if (!validateOtp(otp)) {
errors.otp = "Kode OTP hanya boleh berisi angka"; errors.otp = "Kode OTP harus 4 digit angka";
} }
if (Object.keys(errors).length > 0) { if (Object.keys(errors).length > 0) {
return json<VerifyOTPActionData>({ errors }, { status: 400 }); return json<VerifyOTPActionData>({ errors }, { status: 400 });
} }
// Simulasi verifikasi OTP - dalam implementasi nyata, cek ke database/cache try {
if (otp === "1234") { // Generate device ID
// OTP valid, lanjut ke step berikutnya const deviceId = generateDeviceId("pengelola_");
return redirect(
`/authpengelola/completingcompanyprofile?phone=${encodeURIComponent( // Call API untuk verifikasi OTP
phone 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>( return json<VerifyOTPActionData>(
@ -289,10 +361,12 @@ export default function VerifyOTPToRegister() {
)} )}
{/* Error Alert */} {/* Error Alert */}
{actionData?.errors?.otp && ( {(actionData?.errors?.otp || actionData?.errors?.general) && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription>{actionData.errors.otp}</AlertDescription> <AlertDescription>
{actionData.errors.otp || actionData.errors.general}
</AlertDescription>
</Alert> </Alert>
)} )}
@ -407,18 +481,19 @@ export default function VerifyOTPToRegister() {
</CardContent> </CardContent>
</Card> </Card>
{/* Demo Info */} {/* Help Card */}
<Card className="border border-blue-200 bg-blue-50/50 backdrop-blur-sm"> <Card className="border border-gray-200 bg-white/60 backdrop-blur-sm">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-center"> <div className="text-center">
<p className="text-sm font-medium text-blue-800 mb-2">Demo OTP:</p> <p className="text-sm text-gray-600 mb-2">Mengalami kesulitan?</p>
<div className="text-xs text-blue-700 space-y-1"> <a
<p> href="https://wa.me/6281234567890?text=Halo%20saya%20butuh%20bantuan%20verifikasi%20OTP"
Gunakan kode:{" "} target="_blank"
<span className="font-mono font-bold text-lg">1234</span> rel="noopener noreferrer"
</p> className="text-sm text-green-600 hover:text-green-800 font-medium"
<p>Atau tunggu countdown habis untuk test resend</p> >
</div> Hubungi Customer Support
</a>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,21 +1,35 @@
import { json, redirect, type LoaderFunctionArgs } from "@remix-run/node"; import {
import { useLoaderData, Link } from "@remix-run/react"; json,
import { useState, useEffect } from "react"; 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 { Card, CardContent, CardHeader } from "~/components/ui/card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Progress } from "~/components/ui/progress"; import { Progress } from "~/components/ui/progress";
import { import {
Clock, Clock,
CheckCircle, CheckCircle,
Phone,
Mail,
MessageSquare,
RefreshCw, RefreshCw,
FileText, Loader2,
Shield,
AlertCircle, AlertCircle,
Users UserCheck,
ArrowLeft,
ArrowRight,
MessageCircle,
FileCheck
} from "lucide-react"; } from "lucide-react";
import pengelolaAuthService from "~/services/auth/pengelola.service";
import { getUserSession, createUserSession } from "~/sessions.server";
// Progress Indicator Component // Progress Indicator Component
const ProgressIndicator = ({ currentStep = 4, totalSteps = 5 }) => { const ProgressIndicator = ({ currentStep = 4, totalSteps = 5 }) => {
@ -56,84 +70,180 @@ const ProgressIndicator = ({ currentStep = 4, totalSteps = 5 }) => {
); );
}; };
// Interface // Interfaces
interface LoaderData { interface LoaderData {
phone: string; userSession: any;
submittedAt: string; lastChecked: string;
estimatedApprovalTime: string; // "1-3 hari kerja" }
applicationId: string;
interface CheckApprovalActionData {
success?: boolean;
approved?: boolean;
message?: string;
errors?: {
general?: string;
};
} }
export const loader = async ({ export const loader = async ({
request request
}: LoaderFunctionArgs): Promise<Response> => { }: LoaderFunctionArgs): Promise<Response> => {
const url = new URL(request.url); const userSession = await getUserSession(request);
const phone = url.searchParams.get("phone");
if (!phone) { // Check if user is authenticated and has pengelola role
return redirect("/authpengelola/requestotpforregister"); 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>({ return json<LoaderData>({
phone, userSession,
submittedAt: new Date().toISOString(), lastChecked: new Date().toISOString()
estimatedApprovalTime: "1-3 hari kerja",
applicationId: "WF" + Date.now().toString().slice(-6)
}); });
}; };
export default function WaitingApprovalFromAdministrator() { export const action = async ({
const { phone, submittedAt, estimatedApprovalTime, applicationId } = request
useLoaderData<LoaderData>(); }: ActionFunctionArgs): Promise<Response> => {
const [timeElapsed, setTimeElapsed] = useState(0); 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(() => { useEffect(() => {
const interval = setInterval(() => { const updateTimeWaiting = () => {
const now = new Date().getTime(); const now = new Date();
const submitted = new Date(submittedAt).getTime(); const submitted = new Date(lastChecked);
const elapsed = Math.floor((now - submitted) / 1000 / 60); // minutes const diffMs = now.getTime() - submitted.getTime();
setTimeElapsed(elapsed);
}, 60000); // Update setiap menit 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); return () => clearInterval(interval);
}, [submittedAt]); }, [lastChecked]);
// Format phone display // Auto refresh every 30 seconds
const formatPhone = (phoneNumber: string) => { useEffect(() => {
if (phoneNumber.length <= 2) return phoneNumber; if (!autoRefresh) return;
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)}`;
};
// Format elapsed time const interval = setInterval(() => {
const formatElapsedTime = (minutes: number) => { // Trigger form submission to check approval
if (minutes < 60) return `${minutes} menit yang lalu`; const form = document.getElementById(
const hours = Math.floor(minutes / 60); "check-approval-form"
if (hours < 24) return `${hours} jam yang lalu`; ) as HTMLFormElement;
const days = Math.floor(hours / 24); if (form && !isSubmitting) {
return `${days} hari yang lalu`; form.requestSubmit();
}; }
}, 30000); // 30 seconds
// Simulasi status checker - dalam implementasi nyata, polling ke server return () => clearInterval(interval);
const checkStatus = () => { }, [autoRefresh, isSubmitting]);
// Untuk demo, redirect ke step berikutnya setelah beberapa detik
setTimeout(() => {
window.location.href = `/authpengelola/createanewpin?phone=${encodeURIComponent(
phone
)}`;
}, 2000);
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -143,219 +253,202 @@ export default function WaitingApprovalFromAdministrator() {
{/* Main Card */} {/* Main Card */}
<Card className="border-0 shadow-2xl bg-white/80 backdrop-blur-sm"> <Card className="border-0 shadow-2xl bg-white/80 backdrop-blur-sm">
<CardHeader className="text-center pb-2"> <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"> <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-yellow-600 animate-pulse" /> <Clock className="h-8 w-8 text-orange-600" />
</div> </div>
<h1 className="text-2xl font-bold text-gray-900"> <h1 className="text-2xl font-bold text-gray-900">
Menunggu Persetujuan Administrator Menunggu Persetujuan
</h1> </h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Aplikasi Anda sedang dalam proses verifikasi Profil perusahaan Anda sedang ditinjau oleh administrator
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Status Info */} {/* Status Alert */}
<div className="text-center space-y-2"> {actionData?.success && !actionData.approved && (
<div className="inline-flex items-center space-x-2 px-4 py-2 bg-yellow-50 border border-yellow-200 rounded-full"> <Alert className="border-blue-200 bg-blue-50">
<div className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div> <Clock className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-yellow-800"> <AlertDescription className="text-blue-800">
Status: Dalam Review {actionData.message}
</span> </AlertDescription>
</div> </Alert>
<p className="text-sm text-gray-600"> )}
ID Aplikasi:{" "}
<span className="font-mono font-medium">{applicationId}</span>
</p>
</div>
{/* Progress Bar */} {/* Error Alert */}
<div className="space-y-2"> {actionData?.errors?.general && (
<div className="flex items-center justify-between"> <Alert variant="destructive">
<span className="text-sm font-medium text-gray-700"> <AlertCircle className="h-4 w-4" />
Progress Verifikasi <AlertDescription>{actionData.errors.general}</AlertDescription>
</span> </Alert>
<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>
{/* Submitted Info */} {/* Waiting Status */}
<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 */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-medium text-gray-900 flex items-center"> {/* Progress Animation */}
<FileText className="h-5 w-5 mr-2" /> <div className="relative">
Proses Selanjutnya <div className="flex items-center justify-center space-x-8 py-8">
</h3> <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-1 h-0.5 bg-orange-200 relative overflow-hidden">
<div className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg"> <div className="absolute inset-0 bg-orange-400 animate-pulse"></div>
<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> </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="flex flex-col items-center space-y-2">
<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"> <div className="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center animate-pulse">
2 <UserCheck className="h-6 w-6 text-orange-600" />
</div>
<span className="text-sm font-medium text-orange-600">
Verifikasi Admin
</span>
</div> </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="flex-1 h-0.5 bg-gray-200"></div>
<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 className="flex flex-col items-center space-y-2">
</div> <div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
<div> <CheckCircle className="h-6 w-6 text-gray-400" />
<p className="text-sm font-medium text-gray-900"> </div>
Persetujuan & Aktivasi <span className="text-sm font-medium text-gray-400">
</p> Approved
<p className="text-xs text-gray-600"> </span>
Akun akan diaktivasi dan Anda bisa membuat PIN
</p>
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Contact Info */} {/* Time Waiting */}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg"> <div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="flex items-start space-x-3"> <p className="text-sm text-gray-600">Telah menunggu:</p>
<Users className="h-5 w-5 text-blue-600 mt-0.5" /> <p className="text-lg font-semibold text-gray-900">
<div> {timeWaiting}
<p className="text-sm font-medium text-blue-800 mb-2"> </p>
Butuh Bantuan atau Informasi? </div>
</p>
<div className="space-y-2"> {/* Information Cards */}
<div className="flex items-center space-x-2"> <div className="grid gap-4">
<Phone className="h-4 w-4 text-blue-600" /> <div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<a <div className="flex items-start space-x-3">
href="tel:+6281234567890" <FileCheck className="h-5 w-5 text-blue-600 mt-0.5" />
className="text-sm text-blue-700 hover:text-blue-900 font-medium" <div>
> <p className="text-sm font-medium text-blue-800">
+62 812-3456-7890 Proses Verifikasi
</a> </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>
<div className="flex items-center space-x-2"> </div>
<Mail className="h-4 w-4 text-blue-600" /> </div>
<a
href="mailto:admin@wasteflow.com" <div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
className="text-sm text-blue-700 hover:text-blue-900 font-medium" <div className="flex items-start space-x-3">
> <AlertCircle className="h-5 w-5 text-yellow-600 mt-0.5" />
admin@wasteflow.com <div>
</a> <p className="text-sm font-medium text-yellow-800">
</div> Yang Diverifikasi
<div className="flex items-center space-x-2"> </p>
<MessageSquare className="h-4 w-4 text-blue-600" /> <div className="text-xs text-yellow-700 mt-1 space-y-1">
<a <p> Kebenaran informasi perusahaan</p>
href={`https://wa.me/6281234567890?text=Halo%20saya%20ingin%20bertanya%20tentang%20aplikasi%20${applicationId}`} <p> Validitas dokumen NPWP/Tax ID</p>
target="_blank" <p> Kesesuaian bidang usaha</p>
rel="noopener noreferrer" <p> Kelengkapan data kontak</p>
className="text-sm text-blue-700 hover:text-blue-900 font-medium" </div>
>
WhatsApp Admin
</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Action Buttons */} {/* Check Status Form */}
<div className="space-y-3"> <Form method="post" id="check-approval-form">
<Button <Button
onClick={checkStatus} 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" 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" /> {isSubmitting ? (
Cek Status Persetujuan <>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Mengecek Status...
</>
) : (
<>
<RefreshCw className="mr-2 h-5 w-5" />
Cek Status Persetujuan
</>
)}
</Button> </Button>
</Form>
<Link to="/authpengelola"> {/* Auto Refresh Toggle */}
<Button variant="outline" className="w-full h-12"> <div className="flex items-center justify-center space-x-3 text-sm text-gray-600">
Kembali ke Halaman Utama <label className="flex items-center space-x-2 cursor-pointer">
</Button> <input
</Link> 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> </div>
{/* Important Note */} {/* Contact Support */}
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg"> <div className="text-center space-y-3">
<div className="flex items-start space-x-3"> <p className="text-sm text-gray-600">
<AlertCircle className="h-5 w-5 text-amber-600 mt-0.5" /> Sudah lebih dari 24 jam? Hubungi administrator
<div> </p>
<p className="text-sm font-medium text-amber-800"> <div className="flex flex-col space-y-2">
Penting untuk Diingat <a
</p> href="https://wa.me/6281234567890?text=Halo%20saya%20butuh%20bantuan%20verifikasi%20akun%20pengelola"
<ul className="text-xs text-amber-700 mt-1 space-y-1"> target="_blank"
<li> rel="noopener noreferrer"
Jangan tutup aplikasi ini, bookmark halaman untuk akses className="inline-flex items-center justify-center text-sm text-green-600 hover:text-green-800 font-medium"
mudah >
</li> <MessageCircle className="mr-1 h-4 w-4" />
<li> WhatsApp: +62 812-3456-7890
Anda akan mendapat notifikasi WhatsApp saat disetujui </a>
</li> <a
<li> href="mailto:admin@wasteflow.com?subject=Bantuan%20Verifikasi%20Akun%20Pengelola"
Proses verifikasi dilakukan pada hari kerja (Senin-Jumat) className="text-sm text-blue-600 hover:text-blue-800 font-medium"
</li> >
<li> Email: admin@wasteflow.com
Pastikan nomor WhatsApp aktif untuk menerima notifikasi </a>
</li>
</ul>
</div>
</div> </div>
</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> </CardContent>
</Card> </Card>
{/* Demo Card */} {/* Tips Card */}
<Card className="border border-green-200 bg-green-50/50 backdrop-blur-sm"> <Card className="border border-gray-200 bg-white/60 backdrop-blur-sm">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-center"> <div className="text-center space-y-2">
<p className="text-sm font-medium text-green-800 mb-2"> <p className="text-sm font-medium text-gray-700">
Demo Mode: 💡 Tips: Pastikan informasi yang diberikan akurat
</p> </p>
<p className="text-xs text-green-700"> <p className="text-xs text-gray-600">
Klik "Cek Status Persetujuan" untuk simulasi approval dan lanjut Jika ada kesalahan data, admin akan menghubungi Anda untuk revisi
ke step terakhir
</p> </p>
</div> </div>
</CardContent> </CardContent>