feat: initial commit with seeded database and premium ui changes

This commit is contained in:
Dodo
2026-06-11 18:41:42 +07:00
commit edf94ae5c1
1684 changed files with 33073 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from "@/components/ui/breadcrumb";
/** Current page segment shown in the header — pass a nav item or `{ title, icon? }`. */
export type AppBreadcrumbPage = {
title: string;
icon?: ReactNode;
};
export function AppBreadcrumbs({ page }: { page?: AppBreadcrumbPage | null }) {
if (!page?.title) {
return null;
}
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage className="flex items-center gap-2 [&>svg]:size-3.5">
{page.icon}
{page.title}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { DecorIcon } from "@/components/decor-icon";
import { AppBreadcrumbs } from "@/components/app-breadcrumbs";
import { MapsBreadcrumb } from "@/components/maps/maps-breadcrumb";
import { getNavLinks } from "@/components/app-shared";
import { CustomSidebarTrigger } from "@/components/custom-sidebar-trigger";
import { NavUser } from "@/components/nav-user";
import { TrendingDownIcon, RoadHorizonIcon, FuelIcon } from "@/components/ui/phosphor-icons";
const MAP_PAGES: Record<string, { label: string; icon: React.ReactNode }> = {
"/dashboard/poverty": { label: "Peta Kemiskinan", icon: <TrendingDownIcon className="size-3.5" /> },
"/dashboard/land": { label: "Lahan dan Jalan", icon: <RoadHorizonIcon className="size-3.5" /> },
"/dashboard/gas-station": { label: "SPBU dan Charger", icon: <FuelIcon className="size-3.5" /> },
};
export function AppHeader({ activePath, fullBleed = false, onExitFullBleed }: { activePath?: string; fullBleed?: boolean; onExitFullBleed?: () => void }) {
const links = getNavLinks(activePath);
const activeItem = links.find((item) => item.isActive);
const mapPage = activePath ? MAP_PAGES[activePath] : undefined;
return (
<header
className={cn(
"sticky top-0 z-50 flex h-14 shrink-0 items-center justify-between gap-2 border-b px-4 md:px-6",
"bg-background/95 backdrop-blur-sm supports-backdrop-filter:bg-background/50"
)}
>
<DecorIcon className="hidden md:block" position="bottom-left" />
<div className="flex items-center gap-3">
<CustomSidebarTrigger />
<Separator
className="mr-2 h-4 data-[orientation=vertical]:self-center"
orientation="vertical"
/>
{mapPage ? (
<MapsBreadcrumb currentPage={mapPage.label} currentIcon={mapPage.icon} isLeafletOpen={fullBleed} onExitMap={onExitFullBleed} />
) : (
<AppBreadcrumbs page={activeItem} />
)}
</div>
<div className="flex items-center gap-3">
<NavUser />
</div>
</header>
);
}
+121
View File
@@ -0,0 +1,121 @@
import type { ReactNode } from "react";
import {
FuelIcon,
SettingsIcon,
HelpCircleIcon,
BookOpenIcon,
TrendingDownIcon,
RoadHorizonIcon,
HouseIcon
} from "@/components/ui/phosphor-icons";
export type SidebarNavItem = {
title: string;
path?: string;
icon?: ReactNode;
isActive?: boolean;
subItems?: SidebarNavItem[];
};
export type SidebarNavGroup = {
label?: string;
items: SidebarNavItem[];
};
const ROLE_ALLOWED_MAP_PATHS: Record<string, string[]> = {
superadmin: ["/dashboard/poverty", "/dashboard/land", "/dashboard/gas-station"],
admin_poverty: ["/dashboard/poverty"],
admin_lands_roads: ["/dashboard/land"],
admin_gas_stations: ["/dashboard/gas-station"],
};
const checkIsActive = (activePath?: string, itemPath?: string): boolean => {
if (!activePath || !itemPath) return false;
if (itemPath.startsWith("#")) return false; // Ignore hash placeholders
if (itemPath === "/dashboard") {
return activePath === "/dashboard";
}
return activePath === itemPath || activePath.startsWith(itemPath + "/");
};
export const getNavGroups = (activePath?: string, role?: string): SidebarNavGroup[] => {
const allowedMapPaths = role ? (ROLE_ALLOWED_MAP_PATHS[role] ?? []) : null;
const allMapItems = [
{
title: "Peta Kemiskinan",
path: "/dashboard/poverty",
icon: <TrendingDownIcon />,
},
{
title: "Lahan & Jalan",
path: "/dashboard/land",
icon: <RoadHorizonIcon />,
},
{
title: "SPBU & Charger",
path: "/dashboard/gas-station",
icon: <FuelIcon />,
},
];
const mapItems = allMapItems
.filter((item) => !allowedMapPaths || allowedMapPaths.includes(item.path))
.map((item) => ({ ...item, isActive: checkIsActive(activePath, item.path) }));
return [
{
label: "Ikhtisar",
items: [
{
title: "Beranda",
path: "/dashboard",
icon: <HouseIcon />,
isActive: checkIsActive(activePath, "/dashboard"),
},
],
},
{
label: "Peta Visual",
items: mapItems,
},
{
label: "Administrasi",
items: [
{
title: "Pengaturan",
path: "/dashboard/settings",
icon: <SettingsIcon />,
isActive: checkIsActive(activePath, "/dashboard/settings"),
},
],
},
];
};
export const navGroups = getNavGroups();
export const footerNavLinks: SidebarNavItem[] = [
{
title: "Pusat Bantuan",
path: "#/help",
icon: <HelpCircleIcon />,
},
{
title: "Dokumentasi",
path: "#/documentation",
icon: <BookOpenIcon />,
},
];
export const getNavLinks = (activePath?: string, role?: string): SidebarNavItem[] => [
...getNavGroups(activePath, role).flatMap((group) =>
group.items.flatMap((item) =>
item.subItems?.length ? [item, ...item.subItems] : [item]
)
),
...footerNavLinks,
];
export const navLinks = getNavLinks();
+39
View File
@@ -0,0 +1,39 @@
import { cn } from "@/lib/utils";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { AppHeader } from "@/components/app-header";
import { AppSidebar } from "@/components/app-sidebar";
export function AppShell({
children,
activePath,
fullBleed,
onExitFullBleed,
hideHeader = false
}: {
children: React.ReactNode;
activePath?: string;
fullBleed?: boolean;
onExitFullBleed?: () => void;
hideHeader?: boolean;
}) {
return (
<SidebarProvider className={cn("[--app-wrapper-max-width:80rem]", fullBleed && "h-screen overflow-hidden")}>
<AppSidebar activePath={activePath} />
<SidebarInset className={cn(fullBleed && "h-screen overflow-hidden flex flex-col")}>
{!hideHeader && (
<AppHeader activePath={activePath} fullBleed={fullBleed} onExitFullBleed={onExitFullBleed} />
)}
<div
className={cn(
"flex flex-1 flex-col",
fullBleed
? cn("p-0 max-w-none overflow-hidden", hideHeader ? "h-screen" : "h-[calc(100vh-3.5rem)]")
: "p-4 md:p-6 mx-auto w-full max-w-(--app-wrapper-max-width)"
)}
>
{children}
</div>
</SidebarInset>
</SidebarProvider>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { cn } from "@/lib/utils";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { footerNavLinks, getNavGroups } from "@/components/app-shared";
import { NavGroup } from "@/components/nav-group";
import { useAuth } from "@/lib/auth-context";
export function AppSidebar({ activePath }: { activePath?: string }) {
const { user } = useAuth();
const groups = getNavGroups(activePath, user?.role);
return (
<Sidebar
className={cn(
"*:data-[slot=sidebar-inner]:bg-background",
"*:data-[slot=sidebar-inner]:dark:bg-[radial-gradient(60%_18%_at_10%_0%,--theme(--color-foreground/.08),transparent)]",
// Default text/icon colors for sidebar buttons (faded)
"**:data-[slot=sidebar-menu-button]:[&>span]:text-foreground/60",
"**:data-[slot=sidebar-menu-button]:[&>svg]:text-foreground/50",
"**:data-[slot=sidebar-menu-button]:[&>span]:transition-colors",
"**:data-[slot=sidebar-menu-button]:[&>svg]:transition-colors",
// Hover states for sidebar buttons
"**:data-[slot=sidebar-menu-button]:hover:bg-sidebar-accent/50",
"**:data-[slot=sidebar-menu-button]:hover:[&>span]:text-foreground",
"**:data-[slot=sidebar-menu-button]:hover:[&>svg]:text-foreground",
// Active state (Vermilion Pulse theme color)
"**:data-[slot=sidebar-menu-button]:data-[active]:bg-primary/10!",
"**:data-[slot=sidebar-menu-button]:data-[active]:[&>span]:text-primary!",
"**:data-[slot=sidebar-menu-button]:data-[active]:[&>span]:font-semibold",
"**:data-[slot=sidebar-menu-button]:data-[active]:[&>svg]:text-primary!",
"**:data-[slot=sidebar-menu-button]:data-[active]:hover:bg-primary/15!",
// Default text/icon colors for sidebar sub-buttons (faded)
"**:data-[slot=sidebar-menu-sub-button]:[&>span]:text-foreground/60",
"**:data-[slot=sidebar-menu-sub-button]:[&>span]:transition-colors",
// Hover states for sub-buttons
"**:data-[slot=sidebar-menu-sub-button]:hover:bg-sidebar-accent/50",
"**:data-[slot=sidebar-menu-sub-button]:hover:[&>span]:text-foreground",
// Active state for sub-buttons
"**:data-[slot=sidebar-menu-sub-button]:data-[active]:bg-primary/10!",
"**:data-[slot=sidebar-menu-sub-button]:data-[active]:[&>span]:text-primary!",
"**:data-[slot=sidebar-menu-sub-button]:data-[active]:[&>span]:font-semibold",
"**:data-[slot=sidebar-menu-sub-button]:data-[active]:hover:bg-primary/15!"
)}
collapsible="icon"
variant="sidebar"
>
<SidebarHeader className="h-14 justify-center border-b px-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<SidebarMenuButton render={<a href="/dashboard" />}><img src="/waras_io/favicon-32x32.png" className="size-5 shrink-0 rounded" alt="Waras Logo" /><span className="font-medium text-foreground!">Waras</span></SidebarMenuButton>
</SidebarHeader>
<SidebarContent>
{groups.map((group, index) => (
<NavGroup key={`sidebar-group-${index}`} {...group} />
))}
</SidebarContent>
<SidebarFooter className="gap-0 p-0">
<SidebarMenu className="border-t p-2">
{footerNavLinks.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton className="text-muted-foreground" isActive={item.isActive} size="sm" tooltip={item.title} render={<a href={item.path} />}>{item.icon}<span>{item.title}</span></SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
<div className="px-4 pt-4 pb-2 transition-opacity group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:opacity-0">
<p className="text-nowrap text-[9px] text-muted-foreground">
© {new Date().getFullYear()} Waras
</p>
</div>
</SidebarFooter>
</Sidebar>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type React from "react";
export function AuthDivider({
children,
...props
}: React.ComponentProps<"div">) {
return (
<div className="relative flex w-full items-center" {...props}>
<div className="w-full border-t" />
<div className="flex w-max justify-center text-nowrap px-2 text-muted-foreground text-xs">
{children}
</div>
<div className="w-full border-t" />
</div>
);
}
+251
View File
@@ -0,0 +1,251 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context";
import { Logo } from "@/components/logo";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { FloatingPaths } from "@/components/floating-paths";
import {
ArrowLeftIcon,
AtSignIcon,
EyeIcon,
EyeOffIcon,
KeyRoundIcon,
} from "@/components/ui/phosphor-icons";
const BACKEND_URL =
process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:4000";
const ROLE_HOME: Record<string, string> = {
superadmin: "/dashboard",
admin_poverty: "/dashboard/poverty",
admin_lands_roads: "/dashboard/land",
admin_gas_stations: "/dashboard/gas-station",
};
const DEMO_CREDENTIALS = [
{
label: "Super Admin",
username: "superadmin",
password: "Super@1234",
routes: "All routes",
},
{
label: "Admin Poverty Map",
username: "admin_poverty",
password: "Poverty@1234",
routes: "/dashboard/poverty only",
},
{
label: "Admin Lahan & Jalan",
username: "admin_lands",
password: "Lands@1234",
routes: "/dashboard/land only",
},
{
label: "Admin SPBU",
username: "admin_gas",
password: "Gas@1234",
routes: "/dashboard/gas-station only",
},
];
export function AuthPage() {
const { login } = useAuth();
const router = useRouter();
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
const res = await fetch(`${BACKEND_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: identifier.trim(),
password,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Login failed");
return;
}
login(data.token, data.user);
const params = new URLSearchParams(window.location.search);
const redirectTo = params.get("redirectTo");
router.replace(
redirectTo ?? ROLE_HOME[data.user.role] ?? "/dashboard",
);
} catch {
setError("Cannot connect to server. Please try again.");
} finally {
setIsLoading(false);
}
}
return (
<main className="relative md:h-screen md:overflow-hidden lg:grid lg:grid-cols-2">
<div className="relative hidden h-full flex-col border-r bg-secondary p-10 lg:flex dark:bg-secondary/20">
<div className="absolute inset-0 bg-linear-to-b from-transparent via-transparent to-background" />
<Logo className="relative z-10 mr-auto h-10 w-auto" />
<div className="z-10 mt-auto">
<blockquote className="space-y-2">
<p className="text-xl">
&ldquo;Aplikasi ini berguna dalam membantu mengelola data spasial
dengan lebih efisien.&rdquo;
</p>
<footer className="font-mono font-semibold text-sm">
~ Pangkywara
</footer>
</blockquote>
</div>
<div className="absolute inset-0">
<FloatingPaths position={1} />
<FloatingPaths position={-1} />
</div>
</div>
<div className="relative flex min-h-screen flex-col justify-center px-8">
<Link
href="/"
className="absolute top-6 left-6 z-20 inline-flex h-6 items-center justify-center gap-2 rounded-md px-3 font-medium text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowLeftIcon className="size-4" />
Kembali
</Link>
{/* Top Shades */}
<div
aria-hidden
className="absolute inset-0 isolate -z-10 opacity-60 contain-strict"
>
<div className="absolute top-0 right-0 h-320 w-140 -translate-y-87.5 rounded-full bg-[radial-gradient(68.54%_68.72%_at_55.02%_31.46%,--theme(--color-foreground/.06)_0,hsla(0,0%,55%,.02)_50%,--theme(--color-foreground/.01)_80%)]" />
<div className="absolute top-0 right-0 h-320 w-60 rounded-full bg-[radial-gradient(50%_50%_at_50%_50%,--theme(--color-foreground/.04)_0,--theme(--color-foreground/.01)_80%,transparent_100%)] [translate:5%_-50%]" />
<div className="absolute top-0 right-0 h-320 w-60 -translate-y-87.5 rounded-full bg-[radial-gradient(50%_50%_at_50%_50%,--theme(--color-foreground/.04)_0,--theme(--color-foreground/.01)_80%,transparent_100%)]" />
</div>
<div className="mx-auto space-y-4 sm:w-sm">
<Logo className="h-4.5 w-auto lg:hidden" />
<div className="flex flex-col space-y-1">
<h1 className="font-bold text-2xl tracking-wide">Login</h1>
<p className="text-base text-muted-foreground">
Masuk ke akun Anda untuk mengakses dashboard.
</p>
</div>
<form className="space-y-3" onSubmit={handleSubmit}>
<InputGroup>
<InputGroupInput
placeholder="Username or email"
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
autoComplete="username"
required
/>
<InputGroupAddon align="inline-start">
<AtSignIcon />
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput
placeholder="Password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
<InputGroupAddon align="inline-start">
<KeyRoundIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
aria-label={
showPassword ? "Hide password" : "Show password"
}
onClick={() => setShowPassword((value) => !value)}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
<Button className="w-full" type="submit" disabled={isLoading}>
{isLoading ? "Logging in…" : "Login Sekarang"}
</Button>
</form>
{/* Quick Demo Credentials */}
<div className="pt-4 border-t border-border/50 flex flex-col gap-2.5">
<div className="flex flex-col gap-0.5">
<h2 className="text-[11px] font-bold text-foreground/60 uppercase tracking-wider">Demo Credentials (Quick Login)</h2>
<p className="text-[11px] text-muted-foreground">Click a role to auto-fill the login form:</p>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{DEMO_CREDENTIALS.map((cred) => (
<button
key={cred.username}
type="button"
onClick={() => {
setIdentifier(cred.username);
setPassword(cred.password);
}}
className="flex flex-col items-start gap-0.5 p-2.5 rounded-lg border border-border bg-card/50 text-left transition-all hover:bg-muted/80 hover:border-primary/40 group cursor-pointer"
>
<span className="text-xs font-bold text-foreground group-hover:text-primary transition-colors">
{cred.label}
</span>
<span className="text-[10px] text-muted-foreground font-mono truncate w-full">
U: {cred.username} | P: {cred.password}
</span>
<span className="text-[9.5px] font-medium text-primary bg-primary/10 px-1 rounded-sm mt-0.5">
{cred.routes}
</span>
</button>
))}
</div>
</div>
</div>
</div>
</main>
);
}
@@ -0,0 +1,22 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { SidebarTrigger } from "@/components/ui/sidebar";
export function CustomSidebarTrigger() {
return (
<Tooltip>
<TooltipTrigger delay={1000} render={<SidebarTrigger />} />
<TooltipContent className="px-2 py-1" side="right">
Toggle Sidebar{" "}
<KbdGroup>
<Kbd></Kbd>
<Kbd>b</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,80 @@
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DashboardCard } from "@/components/dashboard-card";
import {
FuelIcon,
LayersIcon,
UserPlusIcon,
} from "@/components/ui/phosphor-icons";
import type { ActivityItem, ActivityKind } from "@/lib/dashboard-types";
interface DashboardActivityProps {
items: ActivityItem[];
}
const KIND_ICONS: Record<ActivityKind, React.ReactNode> = {
household: <UserPlusIcon />,
land: <LayersIcon />,
gas: <FuelIcon />,
};
function timeAgo(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "";
const diffMs = Date.now() - then;
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 1) return "Baru saja";
if (minutes < 60) return `${minutes} menit lalu`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} jam lalu`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} hari lalu`;
return new Date(iso).toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
});
}
export function DashboardActivity({ items }: DashboardActivityProps) {
return (
<DashboardCard className="gap-0">
<CardHeader className="border-b">
<CardTitle>Aktivitas</CardTitle>
<CardDescription>Pembaruan terbaru di basis data.</CardDescription>
</CardHeader>
<CardContent className="px-0">
{items.length === 0 ? (
<div className="flex h-48 items-center justify-center text-xs text-muted-foreground">
Belum ada aktivitas.
</div>
) : (
<ul className="flex flex-col divide-y divide-border">
{items.map((item) => (
<li className="flex h-16 items-center gap-3 px-6" key={item.id}>
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center [&_svg]:size-4"
>
{KIND_ICONS[item.kind]}
</span>
<div className="min-w-0 flex-1 space-y-1">
<p className="line-clamp-1 text-pretty text-foreground text-sm leading-snug">
{item.title}
</p>
<p className="text-muted-foreground text-xs">
{timeAgo(item.created_at)}
</p>
</div>
</li>
))}
</ul>
)}
</CardContent>
</DashboardCard>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
import type * as React from "react";
import { Card } from "@/components/ui/card";
export function DashboardCard({
className,
...props
}: React.ComponentProps<typeof Card>) {
return (
<Card
className={cn("rounded-none bg-background shadow-none ring-0 border-none", className)}
{...props}
/>
);
}
@@ -0,0 +1,20 @@
import { cn } from "@/lib/utils";
export function DashboardSkeleton() {
return (
<div
className={cn(
"grid grid-cols-2 gap-px bg-border p-px lg:grid-cols-4",
"*:min-h-48 *:w-full *:bg-background/90"
)}
>
<div />
<div />
<div />
<div />
<div className="col-span-2 min-h-114! lg:col-span-4" />
<div className="col-span-2 min-h-92! lg:col-span-2" />
<div className="col-span-2 min-h-92! lg:col-span-2" />
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { DashboardActivity } from "@/components/dashboard-activity";
import { DashboardSkeleton } from "@/components/dashboard-skeleton";
import { HouseholdsTrendChart } from "@/components/households-trend-chart";
import { PriorityBreakdown } from "@/components/priority-breakdown";
import { PriorityScoreChart } from "@/components/priority-score-chart";
import { RecentHouseholds } from "@/components/recent-households";
import { DashboardStats } from "@/components/stats";
import type { ActivityItem, GasStationStats } from "@/lib/dashboard-types";
import type { HouseholdRow, PovertyOverview } from "@/lib/poverty-types";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
type NamedRow = { id: number; name: string; created_at?: string };
export function Dashboard() {
const [loading, setLoading] = useState(true);
const [overview, setOverview] = useState<PovertyOverview | null>(null);
const [households, setHouseholds] = useState<HouseholdRow[]>([]);
const [gasStats, setGasStats] = useState<GasStationStats | null>(null);
const [landMarkers, setLandMarkers] = useState<NamedRow[]>([]);
const [landShapes, setLandShapes] = useState<NamedRow[]>([]);
const [gasMarkers, setGasMarkers] = useState<NamedRow[]>([]);
// Aggregates everything the overview widgets need, straight from the DB.
useEffect(() => {
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
const get = (path: string) => fetch(`${BACKEND_URL}${path}`, { headers }).then((r) => r.json());
const asRows = (r: PromiseSettledResult<unknown>) =>
r.status === "fulfilled" && Array.isArray(r.value) ? (r.value as NamedRow[]) : [];
Promise.allSettled([
get("/households/stats-overview"),
get("/households"),
get("/gas-station/stats"),
get("/land/markers"),
get("/land/shapes"),
get("/gas-station/markers"),
]).then(([overviewR, hhR, gasStatsR, landMarkersR, landShapesR, gasMarkersR]) => {
if (overviewR.status === "fulfilled" && overviewR.value?.totals) {
setOverview(overviewR.value as PovertyOverview);
}
if (hhR.status === "fulfilled" && Array.isArray(hhR.value)) {
setHouseholds(hhR.value as HouseholdRow[]);
}
if (gasStatsR.status === "fulfilled" && typeof gasStatsR.value?.total === "number") {
setGasStats(gasStatsR.value as GasStationStats);
}
setLandMarkers(asRows(landMarkersR));
setLandShapes(asRows(landShapesR));
setGasMarkers(asRows(gasMarkersR));
setLoading(false);
});
}, []);
const activity = useMemo<ActivityItem[]>(() => {
const items: ActivityItem[] = [
...households.map((h) => ({
id: `hh-${h.id}`,
kind: "household" as const,
title: `Warga "${h.head_name}" terdata`,
created_at: h.created_at,
})),
...landMarkers.map((m) => ({
id: `lm-${m.id}`,
kind: "land" as const,
title: `Penanda lahan "${m.name}" ditambahkan`,
created_at: m.created_at ?? "",
})),
...landShapes.map((s) => ({
id: `ls-${s.id}`,
kind: "land" as const,
title: `Bidang lahan "${s.name}" digambar`,
created_at: s.created_at ?? "",
})),
...gasMarkers.map((g) => ({
id: `gs-${g.id}`,
kind: "gas" as const,
title: `Fasilitas "${g.name}" dipetakan`,
created_at: g.created_at ?? "",
})),
];
return items
.filter((i) => i.created_at)
.sort((a, b) => b.created_at.localeCompare(a.created_at))
.slice(0, 4);
}, [households, landMarkers, landShapes, gasMarkers]);
if (loading) return <DashboardSkeleton />;
return (
<div className="grid grid-cols-1 gap-px bg-border border rounded-xl overflow-hidden md:grid-cols-2 lg:grid-cols-4 shadow-none">
<DashboardStats
overview={overview}
gasStats={gasStats}
landMarkerCount={landMarkers.length}
landShapeCount={landShapes.length}
/>
<HouseholdsTrendChart monthly={overview?.monthly ?? []} />
<PriorityScoreChart monthly={overview?.monthly ?? []} />
<RecentHouseholds households={households.slice(0, 4)} />
<PriorityBreakdown byLabel={overview?.by_label ?? null} />
<DashboardActivity items={activity} />
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { cn } from "@/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
const DecorIconVariants = cva(
"pointer-events-none absolute z-1 size-5 shrink-0 stroke-1 stroke-muted-foreground",
{
variants: {
position: {
"top-left":
"top-0 left-0 -translate-x-[calc(50%+0.5px)] -translate-y-[calc(50%+0.5px)]",
"top-right":
"top-0 right-0 translate-x-[calc(50%+0.5px)] -translate-y-[calc(50%+0.5px)]",
"bottom-right":
"right-0 bottom-0 translate-x-[calc(50%+0.5px)] translate-y-[calc(50%+0.5px)]",
"bottom-left":
"bottom-0 left-0 -translate-x-[calc(50%+0.5px)] translate-y-[calc(50%+0.5px)]",
},
},
defaultVariants: {
position: "top-left",
},
}
);
type DecorIconProps = React.ComponentProps<"svg"> &
VariantProps<typeof DecorIconVariants>;
export function DecorIcon({ position, className, ...props }: DecorIconProps) {
return (
<svg
aria-hidden="true"
className={cn(DecorIconVariants({ position, className }))}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
);
}
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { cn } from "@/lib/utils";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { MinusIcon, TrendingUpIcon, ArrowUpIcon, ChevronUpIcon, TrendingDownIcon, ArrowDownIcon, ChevronDownIcon } from "@/components/ui/phosphor-icons";
type DeltaIconVariant = "default" | "trend" | "arrow";
type DeltaVariant = "default" | "badge";
type DeltaContextValue = {
value: number;
};
const DeltaContext = React.createContext<DeltaContextValue | null>(null);
function useDeltaValue() {
const context = React.useContext(DeltaContext);
if (!context) {
throw new Error(
"DeltaIcon and DeltaValue must be used inside a `Delta` component."
);
}
return context.value;
}
function Delta({
className,
value,
variant = "default",
...props
}: React.ComponentProps<"div"> & {
value: number;
variant?: DeltaVariant;
}) {
return (
<DeltaContext.Provider value={{ value }}>
{variant === "badge" ? (
<Badge
className={cn(
"gap-1 border-none tabular-nums [&_svg]:size-4 [&_svg]:shrink-0",
value > 0
? "bg-emerald-500/10 text-emerald-500"
: "bg-red-500/10 text-red-500",
className
)}
data-slot="delta"
variant="secondary"
{...(props as React.ComponentProps<typeof Badge>)}
/>
) : (
<div
className={cn(
"inline-flex items-center gap-1 text-muted-foreground tabular-nums",
"[&_svg]:size-3 [&_svg]:shrink-0",
value > 0 ? "text-emerald-600 dark:text-emerald-400" : "",
value < 0 ? "text-rose-600 dark:text-rose-400" : "",
className
)}
data-slot="delta"
{...props}
/>
)}
</DeltaContext.Provider>
);
}
function FilledShell({
value,
children,
}: {
value: number;
children: React.ReactNode;
}) {
return (
<span
className={cn(
"inline-flex size-3 shrink-0 items-center justify-center rounded-full",
"[&_svg]:size-2! [&_svg]:shrink-0 [&_svg]:stroke-3! [&_svg]:text-background",
value > 0 && "bg-emerald-500",
value < 0 && "bg-red-500",
(!value || value === 0) && "bg-muted-foreground"
)}
data-slot="delta-icon"
>
{children}
</span>
);
}
function DeltaIcon({
variant = "default",
filled = false,
className,
...props
}: Omit<React.ComponentProps<"svg">, "fill"> & {
variant?: DeltaIconVariant;
filled?: boolean;
}) {
const resolvedValue = useDeltaValue();
const mergedClassName = cn(className);
const shell = (node: React.ReactElement) =>
filled ? <FilledShell value={resolvedValue}>{node}</FilledShell> : node;
const slotProps = filled ? {} : { "data-slot": "delta-icon" as const };
if (!resolvedValue || resolvedValue === 0) {
return shell(
<MinusIcon {...slotProps} className={mergedClassName} {...props} />
);
}
if (resolvedValue > 0) {
if (variant === "trend") {
return shell(
<TrendingUpIcon {...slotProps} className={mergedClassName} {...props} />
);
}
if (variant === "arrow") {
return shell(
<ArrowUpIcon {...slotProps} className={mergedClassName} {...props} />
);
}
return shell(
<ChevronUpIcon {...slotProps} className={mergedClassName} {...props} />
);
}
if (variant === "trend") {
return shell(
<TrendingDownIcon {...slotProps} className={mergedClassName} {...props} />
);
}
if (variant === "arrow") {
return shell(
<ArrowDownIcon {...slotProps} className={mergedClassName} {...props} />
);
}
return shell(
<ChevronDownIcon {...slotProps} className={mergedClassName} {...props} />
);
}
function DeltaValue({
className,
precision = 1,
suffix = "%",
absolute = true,
...props
}: React.ComponentProps<"span"> & {
precision?: number;
suffix?: string;
absolute?: boolean;
}) {
const resolvedValue = useDeltaValue();
const formattedValue = (
absolute ? Math.abs(resolvedValue) : resolvedValue
).toFixed(precision);
return (
<span
className={cn("tabular-nums", className)}
data-slot="delta-value"
{...props}
>
{formattedValue}
{suffix}
</span>
);
}
export { Delta, DeltaIcon, DeltaValue };
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { motion } from "motion/react";
export function FloatingPaths({ position }: { position: number }) {
const paths = Array.from({ length: 36 }, (_, i) => ({
id: i,
d: `M-${380 - i * 5 * position} -${189 + i * 6}C-${
380 - i * 5 * position
} -${189 + i * 6} -${312 - i * 5 * position} ${216 - i * 6} ${
152 - i * 5 * position
} ${343 - i * 6}C${616 - i * 5 * position} ${470 - i * 6} ${
684 - i * 5 * position
} ${875 - i * 6} ${684 - i * 5 * position} ${875 - i * 6}`,
color: `rgba(15,23,42,${0.1 + i * 0.03})`,
width: 0.5 + i * 0.03,
}));
return (
<div className="pointer-events-none absolute inset-0">
<svg
className="h-full w-full text-primary"
fill="none"
viewBox="0 0 696 316"
>
<title>Background Paths</title>
{paths.map((path) => (
<motion.path
animate={{
pathLength: 1,
opacity: [0.3, 0.6, 0.3],
}}
d={path.d}
initial={{ pathLength: 0.3, opacity: 0.6 }}
key={path.id}
stroke="currentColor"
strokeOpacity={0.1 + path.id * 0.03}
strokeWidth={path.width}
transition={{
// Animating pathOffset forces a stroke-dasharray recompute on
// every frame for all 36 paths — opacity alone is far cheaper.
pathLength: { duration: 2, ease: "easeOut" },
opacity: {
duration: 20 + (path.id % 10),
repeat: Number.POSITIVE_INFINITY,
ease: "linear",
},
}}
/>
))}
</svg>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
export const DASHBOARD_LOCALE = "en-US";
/** Noon anchor avoids off-by-one labels around timezone boundaries for ISO date strings. */
export function parseIsoCalendarDate(isoDate: string): Date {
return new Date(`${isoDate}T12:00:00`);
}
export type DashboardDateStyle = "month" | "day-month" | "full";
export function formatDate(isoDate: string, style: DashboardDateStyle): string {
const date = parseIsoCalendarDate(isoDate);
if (style === "month") {
return date.toLocaleDateString(DASHBOARD_LOCALE, { month: "short" });
}
if (style === "day-month") {
return date.toLocaleDateString(DASHBOARD_LOCALE, {
day: "numeric",
month: "short",
});
}
return date.toLocaleDateString(DASHBOARD_LOCALE, {
day: "numeric",
month: "short",
year: "numeric",
});
}
/** X-axis for range charts: weekday when showing ~a week, otherwise month + day. */
export function formatChartAxisTick(
isoDate: string,
periodDays: number
): string {
const date = parseIsoCalendarDate(isoDate);
if (periodDays <= 7) {
return date.toLocaleDateString(DASHBOARD_LOCALE, { weekday: "short" });
}
return formatDate(isoDate, "day-month");
}
export type ChartTooltipWeekdayStyle = "short" | "long";
/** Tooltip label for a chart point (weekday + month + day). */
export function formatChartTooltipDate(
isoDate: string,
weekdayStyle: ChartTooltipWeekdayStyle = "short"
): string {
const date = parseIsoCalendarDate(isoDate);
return date.toLocaleDateString(DASHBOARD_LOCALE, {
weekday: weekdayStyle,
day: "numeric",
month: "short",
});
}
export function formatCompactCurrency(
value: number,
options?: { maximumFractionDigits?: number }
) {
const { maximumFractionDigits = 0 } = options ?? {};
return new Intl.NumberFormat(DASHBOARD_LOCALE, {
currency: "USD",
maximumFractionDigits,
notation: "compact",
style: "currency",
}).format(value);
}
/** Full-precision USD (e.g. average order value). */
export function formatFullCurrency(value: number) {
return new Intl.NumberFormat(DASHBOARD_LOCALE, {
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
style: "currency",
}).format(value);
}
export function formatCompactNumber(value: number) {
return new Intl.NumberFormat(DASHBOARD_LOCALE, {
maximumFractionDigits: 1,
notation: "compact",
}).format(value);
}
/** Whole numbers with grouping (visits, sessions, counts). */
export function formatInteger(value: number) {
return new Intl.NumberFormat(DASHBOARD_LOCALE, {
maximumFractionDigits: 0,
}).format(value);
}
/** Percentage with fixed decimal places (e.g. conversion rate). */
export function formatPercent(value: number, fractionDigits = 2) {
return `${value.toFixed(fractionDigits)}%`;
}
@@ -0,0 +1,151 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { SearchIcon, ZapIcon } from "@/components/ui/phosphor-icons";
import { BRAND_COLORS } from "@/lib/map-data";
import type { UserMarker } from "@/components/leaflet-map";
import { ContinuousTabs } from "@/components/ui/continuous-tabs";
interface GasStationDirectoryProps {
searchQuery: string;
setSearchQuery: (query: string) => void;
brandFilter: string;
setBrandFilter: (brand: string) => void;
evOnlyFilter: boolean;
setEvOnlyFilter: (evOnly: boolean) => void;
filteredStations: UserMarker[];
selectedStationId: string;
onSelectStation: (station: UserMarker) => void;
}
export function GasStationDirectory({
searchQuery,
setSearchQuery,
brandFilter,
setBrandFilter,
evOnlyFilter,
setEvOnlyFilter,
filteredStations,
selectedStationId,
onSelectStation,
}: GasStationDirectoryProps) {
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
<CardHeader className="border-b bg-muted/20 px-6 py-4 lg:h-[73px] lg:py-0 flex flex-col justify-center">
<CardTitle className="text-base font-semibold">Direktori Stasiun Aktif</CardTitle>
<CardDescription>Menyaring {filteredStations.length} target stasiun regional aktif dari database.</CardDescription>
</CardHeader>
<CardContent className="p-6 flex-1">
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-4 items-center">
<div className="relative flex-grow w-full">
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cari berdasarkan nama lokasi..."
className="pl-9 bg-background h-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<ContinuousTabs
tabs={[
{ id: "All", label: "Semua" },
{ id: "Pertamina", label: "Pertamina" },
{ id: "Shell", label: "Shell" },
{ id: "BP", label: "BP" },
{ id: "Vivo", label: "Vivo" },
]}
activeId={brandFilter}
onChange={setBrandFilter}
size="sm"
/>
</div>
<div className="flex items-center gap-2 mb-4">
<Button
variant={evOnlyFilter ? "default" : "outline"}
onClick={() => setEvOnlyFilter(!evOnlyFilter)}
className="h-8 text-xs gap-1.5"
>
<ZapIcon className="h-3.5 w-3.5 text-amber-500 fill-amber-500" />
Pengisi Daya EV Tersedia
</Button>
</div>
{/* Grid List */}
{filteredStations.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground">
Tidak ada lokasi yang cocok. Tambah lokasi baru melalui peta stasiun.
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 max-h-[360px] overflow-y-auto pr-1">
{filteredStations.map((station) => {
const isSelected = selectedStationId === station.id;
const brandName = station.type === "gas-pump" ? station.meta?.poi_type : null;
const brandColor = BRAND_COLORS[brandName as keyof typeof BRAND_COLORS] || { bg: "#6b7280", text: "#ffffff" };
return (
<div
key={station.id}
onClick={() => onSelectStation(station)}
className={`p-4 border rounded-lg cursor-pointer flex flex-col gap-1.5 transition-all ${
isSelected
? "border-primary bg-primary/5 font-semibold"
: "border-border/60 hover:bg-muted"
}`}
>
<div className="flex justify-between items-start">
<span className="text-xs text-foreground font-bold">{station.name}</span>
{station.type === "gas-pump" && brandName ? (
<span
className="text-[9px] uppercase font-bold px-2 py-0.5 rounded border shrink-0 ml-2"
style={{
color: brandColor.bg,
borderColor: `${brandColor.bg}33`,
backgroundColor: `${brandColor.bg}11`
}}
>
{brandName}
</span>
) : (
<span className="text-[9px] uppercase font-bold px-2 py-0.5 rounded border shrink-0 ml-2 bg-primary/10 text-primary border-primary/20">
{station.type === "charging-station" ? "EV Charger" : "Bengkel"}
</span>
)}
</div>
<p className="text-[10px] text-muted-foreground font-mono tabular-nums">
Koordinat: {station.lat.toFixed(5)}, {station.lng.toFixed(5)}
</p>
{station.type === "gas-pump" && station.meta?.gas_types && station.meta.gas_types.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{station.meta.gas_types.map((gt) => (
<span
key={gt}
className="px-1.5 py-0.5 rounded text-[8px] font-medium bg-muted text-muted-foreground border border-border/40"
>
{gt}
</span>
))}
</div>
)}
<div className="flex justify-between items-center text-[10px] mt-1 text-muted-foreground border-t pt-2 mt-auto">
{station.type === "gas-pump" ? (
<span>Operasional: {station.meta?.notes?.replace("Jam: ", "") || "24 Jam"}</span>
) : station.type === "charging-station" ? (
<span>Tipe: {station.meta?.poi_type} · {station.meta?.notes}</span>
) : (
<span>Spesialisasi: {station.meta?.poi_type} · {station.meta?.notes}</span>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,52 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ArrowLeftIcon } from "@/components/ui/phosphor-icons";
import { ContinuousTabs } from "@/components/ui/continuous-tabs";
interface GasStationHudProps {
fuelPriceDisplay: "ron92" | "ron95" | "diesel";
setFuelPriceDisplay: (price: "ron92" | "ron95" | "diesel") => void;
brandFilter: string;
setBrandFilter: (brand: string) => void;
onCloseMap: () => void;
}
export function GasStationHud({
fuelPriceDisplay,
setFuelPriceDisplay,
brandFilter,
setBrandFilter,
onCloseMap,
}: GasStationHudProps) {
return (
<div className="absolute top-4 left-4 z-[999] flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3 bg-background/90 backdrop-blur-md border border-border/40 p-2.5 rounded-lg shadow-lg max-w-[90vw]">
<span className="text-[10px] font-bold text-muted-foreground">TAMPILAN:</span>
<ContinuousTabs
tabs={[
{ id: "ron92", label: "RON 92" },
{ id: "ron95", label: "RON 95" },
{ id: "diesel", label: "Diesel" },
]}
activeId={fuelPriceDisplay}
onChange={(id) => setFuelPriceDisplay(id as any)}
size="sm"
/>
<Separator orientation="vertical" className="h-5" />
<ContinuousTabs
tabs={[
{ id: "All", label: "Semua" },
{ id: "Pertamina", label: "Pertamina" },
{ id: "Shell", label: "Shell" },
{ id: "BP", label: "BP" },
]}
activeId={brandFilter}
onChange={setBrandFilter}
size="sm"
/>
</div>
</div>
);
}
@@ -0,0 +1,166 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { MapPinIcon, ZapIcon, ClockIcon, NavigationIcon } from "@/components/ui/phosphor-icons";
import { BRAND_COLORS } from "@/lib/map-data";
import type { UserMarker } from "@/components/leaflet-map";
interface GasStationInspectorProps {
selectedStation: UserMarker | null;
}
const BRAND_PRICES = {
Pertamina: { ron92: 13200, ron95: 14400, diesel: 14900 },
Shell: { ron92: 13850, ron95: 14600, diesel: 15100 },
BP: { ron92: 13400, ron95: 14500, diesel: 14800 },
Vivo: { ron92: 13400, ron95: 14500, diesel: 14800 }
};
export function GasStationInspector({ selectedStation }: GasStationInspectorProps) {
if (!selectedStation) {
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-center items-center h-full p-6 text-center text-muted-foreground/60 border-l border-border/40">
<MapPinIcon className="h-8 w-8 text-muted-foreground/30 mb-2" />
<p className="text-xs">Pilih stasiun dari direktori atau peta untuk melihat detail.</p>
</Card>
);
}
const isGasPump = selectedStation.type === "gas-pump";
const isEvCharger = selectedStation.type === "charging-station";
const isWrench = selectedStation.type === "wrench";
const brandName = isGasPump ? selectedStation.meta?.poi_type : null;
const brandColor = BRAND_COLORS[brandName as keyof typeof BRAND_COLORS] || { bg: "#6b7280", text: "#ffffff" };
// Get price index for gas stations
const prices = isGasPump
? (BRAND_PRICES[brandName as keyof typeof BRAND_PRICES] || BRAND_PRICES.Pertamina)
: null;
// Extract clean values from meta
const cleanNotes = selectedStation.meta?.notes ?? "";
const cleanPoiType = selectedStation.meta?.poi_type ?? "";
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full border-l border-border/40">
<CardHeader className="border-b bg-muted/20 px-6 py-4 lg:h-[73px] lg:py-0 flex flex-col justify-center">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<MapPinIcon className="h-4 w-4 text-primary" />
Detail Stasiun
</CardTitle>
<CardDescription>Metrik stasiun langsung &amp; detail dari database.</CardDescription>
</CardHeader>
<CardContent className="p-6 flex flex-col gap-5 flex-1 overflow-y-auto">
<div>
<h3 className="text-xl font-bold text-foreground leading-tight">{selectedStation.name}</h3>
<span
className="inline-block mt-2 text-[9px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border"
style={isGasPump && brandName ? {
color: brandColor.bg,
borderColor: `${brandColor.bg}33`,
backgroundColor: `${brandColor.bg}11`
} : undefined}
>
{isGasPump ? `Jaringan ${brandName}` : isEvCharger ? "Pengisi Daya EV" : "Bengkel (Repair Shop)"}
</span>
</div>
{/* Gas Pump: Pricing Cards */}
{isGasPump && prices && (
<div className="flex flex-col gap-2 border rounded-lg p-3 bg-muted/10">
<div className="flex justify-between items-center text-xs py-1 border-b">
<span className="font-semibold text-foreground">Harga RON 92</span>
<span className="font-bold text-foreground">Rp {prices.ron92.toLocaleString()} / L</span>
</div>
<div className="flex justify-between items-center text-xs py-1 border-b">
<span className="font-semibold text-primary font-semibold">Harga RON 95</span>
<span className="font-bold text-primary">Rp {prices.ron95.toLocaleString()} / L</span>
</div>
<div className="flex justify-between items-center text-xs py-1">
<span className="font-semibold text-foreground">Harga Diesel</span>
<span className="font-bold text-foreground">Rp {prices.diesel.toLocaleString()} / L</span>
</div>
</div>
)}
{/* Gas Pump: Fuel Types */}
{isGasPump && selectedStation.meta?.gas_types && selectedStation.meta.gas_types.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-muted-foreground">Tipe Bahan Bakar</span>
<div className="flex flex-wrap gap-1">
{selectedStation.meta.gas_types.map((gt) => (
<span
key={gt}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-primary/10 text-primary border border-primary/20"
>
{gt}
</span>
))}
</div>
</div>
)}
{/* EV Charger Details */}
{isEvCharger && (
<div className="flex flex-col gap-3 border rounded-lg p-3 bg-muted/15">
<div className="flex justify-between items-center text-xs py-1 border-b">
<span className="font-semibold text-muted-foreground">Tipe Charger</span>
<span className="font-bold text-foreground">{cleanPoiType}</span>
</div>
<div className="flex justify-between items-center text-xs py-1">
<span className="font-semibold text-muted-foreground">Status Operasional</span>
<span className={`font-bold px-2 py-0.5 rounded text-[10px] uppercase ${
cleanNotes.includes("Active") ? "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20" :
cleanNotes.includes("Maintenance") ? "bg-amber-500/10 text-amber-600 border border-amber-500/20" :
"bg-red-500/10 text-red-600 border border-red-500/20"
}`}>
{cleanNotes.replace("Status: ", "")}
</span>
</div>
</div>
)}
{/* Repair Shop Details */}
{isWrench && (
<div className="flex flex-col gap-3 border rounded-lg p-3 bg-muted/15">
<div className="flex justify-between items-center text-xs py-1 border-b">
<span className="font-semibold text-muted-foreground">Spesialisasi</span>
<span className="font-bold text-foreground">{cleanPoiType}</span>
</div>
<div className="flex justify-between items-center text-xs py-1">
<span className="font-semibold text-muted-foreground">Status / Jam Buka</span>
<span className="font-bold text-foreground">{cleanNotes}</span>
</div>
</div>
)}
{/* General: Operating Hours HUD style */}
{isGasPump && (
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-muted-foreground">Jam Operasional</span>
<div className="flex items-center gap-3 p-3 border rounded-lg bg-background">
<ClockIcon className="h-6 w-6 shrink-0 text-emerald-500" />
<div>
<p className="text-xs font-bold text-foreground">Buka Layanan</p>
<p className="text-[10px] text-muted-foreground">{cleanNotes.replace("Jam: ", "") || "24 Jam"}</p>
</div>
</div>
</div>
)}
{/* Coordinates & Route Buttons */}
<div className="border-t pt-4 text-xs text-muted-foreground flex flex-col gap-2 mt-auto">
<p className="flex items-center gap-1 font-mono">
<MapPinIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
Lat: {selectedStation.lat.toFixed(5)}, Lng: {selectedStation.lng.toFixed(5)}
</p>
<Button className="w-full mt-2 h-9 gap-1.5" variant="outline">
<NavigationIcon className="h-4 w-4" /> Dapatkan Arah Rute
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,117 @@
import { Card } from "@/components/ui/card";
import { FuelIcon } from "@/components/ui/phosphor-icons";
export interface FuelPriceData {
region: string;
pertalite: number | null;
pertamax: number | null;
pertamaxGreen: number | null;
pertamaxTurbo: number | null;
pertamaxPertashop: number | null;
pertaminaDex: number | null;
dexlite: number | null;
bioSolarNonSubsidi: number | null;
bioSolarSubsidi: number | null;
}
export interface FuelPricesResponse {
succeeded: boolean;
lastUpdated: string;
gasoline: FuelPriceData[];
diesel: FuelPriceData[];
}
interface GasStationStatsCardsProps {
fuelPrices: FuelPricesResponse | null;
loading: boolean;
}
export function GasStationStatsCards({ fuelPrices, loading }: GasStationStatsCardsProps) {
// Find Kalimantan Barat prices
const kalbarGas = fuelPrices?.gasoline?.find(
(g) => g.region === "Prov. Kalimantan Barat"
);
const kalbarDiesel = fuelPrices?.diesel?.find(
(d) => d.region === "Prov. Kalimantan Barat"
);
const formatPrice = (p: number | null | undefined) => {
if (loading) return "Loading...";
if (p == null) return "Rp —";
return "Rp " + p.toLocaleString("id-ID");
};
const lastUpdatedText = fuelPrices?.lastUpdated || "Update terbaru";
return (
<>
{/* Card 1: Pertalite */}
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<div className="px-6 pt-6 pb-4">
<div className="flex justify-between items-center pb-1">
<span className="text-xs font-medium text-muted-foreground">Pertalite (RON 90)</span>
<FuelIcon className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-3xl font-bold text-foreground mt-2">
{formatPrice(kalbarGas?.pertalite)}
</div>
</div>
<div className="border-t px-6 py-3 flex items-center justify-between text-[10px] text-muted-foreground mt-auto font-sans">
<span>Prov. Kalimantan Barat</span>
<span className="text-muted-foreground/80">{lastUpdatedText}</span>
</div>
</Card>
{/* Card 2: Pertamax */}
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<div className="px-6 pt-6 pb-4">
<div className="flex justify-between items-center pb-1">
<span className="text-xs font-medium text-muted-foreground">Pertamax (RON 92)</span>
<FuelIcon className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-3xl font-bold text-foreground mt-2">
{formatPrice(kalbarGas?.pertamax)}
</div>
</div>
<div className="border-t px-6 py-3 flex items-center justify-between text-[10px] text-muted-foreground mt-auto font-sans">
<span>Prov. Kalimantan Barat</span>
<span className="text-muted-foreground/80">{lastUpdatedText}</span>
</div>
</Card>
{/* Card 3: Dexlite */}
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<div className="px-6 pt-6 pb-4">
<div className="flex justify-between items-center pb-1">
<span className="text-xs font-medium text-muted-foreground">Dexlite (Diesel)</span>
<FuelIcon className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-3xl font-bold text-foreground mt-2">
{formatPrice(kalbarDiesel?.dexlite)}
</div>
</div>
<div className="border-t px-6 py-3 flex items-center justify-between text-[10px] text-muted-foreground mt-auto font-sans">
<span>Prov. Kalimantan Barat</span>
<span className="text-muted-foreground/80">{lastUpdatedText}</span>
</div>
</Card>
{/* Card 4: Bio Solar (Subsidi) */}
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<div className="px-6 pt-6 pb-4">
<div className="flex justify-between items-center pb-1">
<span className="text-xs font-medium text-muted-foreground">Bio Solar (Subsidi)</span>
<FuelIcon className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-3xl font-bold text-foreground mt-2">
{formatPrice(kalbarDiesel?.bioSolarSubsidi)}
</div>
</div>
<div className="border-t px-6 py-3 flex items-center justify-between text-[10px] text-muted-foreground mt-auto font-sans">
<span>Prov. Kalimantan Barat</span>
<span className="text-muted-foreground/80">{lastUpdatedText}</span>
</div>
</Card>
</>
);
}
@@ -0,0 +1,156 @@
"use client";
import { useState, useMemo } from "react";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { DashboardCard } from "@/components/dashboard-card";
import { Input } from "@/components/ui/input";
import { SearchIcon, FuelIcon } from "@/components/ui/phosphor-icons";
import { ContinuousTabs } from "@/components/ui/continuous-tabs";
import type { FuelPricesResponse } from "./gas-station-stats-cards";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface PertaminaPriceTableProps {
fuelPrices: FuelPricesResponse | null;
loading: boolean;
}
export function PertaminaPriceTable({ fuelPrices, loading }: PertaminaPriceTableProps) {
const [activeTab, setActiveTab] = useState<string>("gasoline");
const [searchQuery, setSearchQuery] = useState<string>("");
const dataList = useMemo(() => {
if (!fuelPrices) return [];
return activeTab === "gasoline" ? fuelPrices.gasoline : fuelPrices.diesel;
}, [fuelPrices, activeTab]);
const filteredData = useMemo(() => {
return dataList.filter((row) =>
row.region?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [dataList, searchQuery]);
const formatVal = (val: number | null) => {
if (val == null) return <span className="text-muted-foreground/45"></span>;
return <span className="font-semibold text-foreground">Rp {val.toLocaleString("id-ID")}</span>;
};
return (
<DashboardCard className="relative gap-0 h-full flex flex-col">
<CardHeader className="border-b border-border/60 bg-muted/10 px-6 py-4 flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div>
<CardTitle className="text-base font-semibold flex items-center gap-2">
<FuelIcon className="h-5 w-5 text-primary" />
Daftar Harga BBM Pertamina Seluruh Indonesia
</CardTitle>
<CardDescription>
Data real-time disinkronkan dari Pertamina Patra Niaga ({fuelPrices?.lastUpdated || "Update terbaru"}).
</CardDescription>
</div>
<div className="flex flex-col sm:flex-row gap-3 items-center w-full sm:w-auto">
<div className="relative w-full sm:w-64">
<SearchIcon className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cari provinsi..."
className="pl-9 bg-background h-9 text-xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<ContinuousTabs
tabs={[
{ id: "gasoline", label: "Bensin (Gasoline)" },
{ id: "diesel", label: "Solar (Gasoil)" },
]}
activeId={activeTab}
onChange={setActiveTab}
size="sm"
/>
</div>
</CardHeader>
<CardContent className="p-0 flex-1 flex flex-col">
{loading ? (
<div className="py-12 text-center text-sm text-muted-foreground">
Memuat data harga BBM terbaru...
</div>
) : filteredData.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
Tidak ada data provinsi yang cocok.
</div>
) : (
<div className="overflow-x-auto w-full max-h-[420px] overflow-y-auto">
<Table>
<TableHeader className="bg-muted/30 sticky top-0 backdrop-blur z-10">
<TableRow>
<TableHead className="px-6 py-3 min-w-[200px] text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Provinsi / Wilayah</TableHead>
{activeTab === "gasoline" ? (
<>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertalite (90)</TableHead>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamax (92)</TableHead>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamax Green 95</TableHead>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamax Turbo (98)</TableHead>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamax Pertashop</TableHead>
</>
) : (
<>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Bio Solar (Subsidi)</TableHead>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Bio Solar (Non-Sub)</TableHead>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Dexlite</TableHead>
<TableHead className="px-6 py-3 text-right text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Pertamina Dex</TableHead>
</>
)}
</TableRow>
</TableHeader>
<TableBody>
{filteredData.map((row) => {
const isKalbar = row.region === "Prov. Kalimantan Barat";
return (
<TableRow
key={row.region}
className={`text-xs transition-colors hover:bg-muted/40 h-12 ${
isKalbar
? "bg-amber-500/5 hover:bg-amber-500/10 border-l-4 border-l-amber-500 font-semibold"
: ""
}`}
>
<TableCell className="px-6 py-3.5 font-medium flex items-center gap-2">
{isKalbar && (
<span className="h-1.5 w-1.5 rounded-full bg-amber-500 animate-pulse" />
)}
{row.region}
</TableCell>
{activeTab === "gasoline" ? (
<>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertalite)}</TableCell>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertamax)}</TableCell>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertamaxGreen)}</TableCell>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertamaxTurbo)}</TableCell>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertamaxPertashop)}</TableCell>
</>
) : (
<>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.bioSolarSubsidi)}</TableCell>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.bioSolarNonSubsidi)}</TableCell>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.dexlite)}</TableCell>
<TableCell className="px-6 py-3.5 text-right">{formatVal(row.pertaminaDex)}</TableCell>
</>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</DashboardCard>
);
}
@@ -0,0 +1,571 @@
"use client";
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/set-state-in-effect */
import { useState, useEffect, useRef } from "react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { UserMarker } from "@/components/leaflet-map";
import { TimedUndoAction } from "@/components/time-undo-action";
import {
EV_TYPES,
EV_STATUSES,
GAS_BRANDS,
REPAIR_SPECIALITIES,
GAS_TYPES_BY_BRAND,
} from "@/components/gas-station/station-placement-dialog";
/* ─── Helpers ───────────────────────────────────── */
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
function ChipSelector({
options,
value,
onChange,
}: {
options: { value: string; label: string; color: string }[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const isSelected = value === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
isSelected
? "border-transparent text-white shadow-sm animate-in fade-in zoom-in-95 duration-150"
: "border-border text-foreground/70 hover:bg-muted"
)}
style={isSelected ? { backgroundColor: opt.color } : undefined}
>
{opt.label}
</button>
);
})}
</div>
);
}
function MultiChipSelector({
options,
values,
onChange,
color,
}: {
options: string[];
values: string[];
onChange: (v: string[]) => void;
color: string;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const isSelected = values.includes(opt);
return (
<button
key={opt}
type="button"
onClick={() => {
if (isSelected) {
onChange(values.filter((v) => v !== opt));
} else {
onChange([...values, opt]);
}
}}
className={cn(
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
isSelected
? "border-transparent text-white shadow-sm"
: "border-border text-foreground/70 hover:bg-muted"
)}
style={isSelected ? { backgroundColor: color } : undefined}
>
{opt}
</button>
);
})}
</div>
);
}
/* ─── Edit Dialog ───────────────────────────────── */
interface StationEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
marker: UserMarker | null;
onUpdated: (updated: UserMarker) => void;
onDeleted: (deleted: UserMarker) => void;
}
export function StationEditDialog({
open,
onOpenChange,
marker,
onUpdated,
onDeleted,
}: StationEditDialogProps) {
const [category, setCategory] = useState("gas-pump");
const [name, setName] = useState("");
// Form fields for EV Charger
const [evType, setEvType] = useState("DC Fast");
const [evStatus, setEvStatus] = useState("Active");
// Form fields for Gas Station
const [gasBrand, setGasBrand] = useState("Pertamina");
const [gasHours, setGasHours] = useState("24 Jam");
const [selectedGasTypes, setSelectedGasTypes] = useState<string[]>([]);
// Form fields for Repair Shop
const [repairSpeciality, setRepairSpeciality] = useState("Umum");
const [repairStatus, setRepairStatus] = useState("Buka 08:00 - 17:00");
// Timed Delete State
const DELETE_SECONDS = 5;
const TOAST_ID = "station-delete";
const [deleteActive, setDeleteActive] = useState(false);
const [deleteCountdown, setDeleteCountdown] = useState(DELETE_SECONDS);
const pendingDeleteMarker = useRef<UserMarker | null>(null);
// Fetching and saving state
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
// Synchronize when marker opens and load latest from DB
useEffect(() => {
if (!open || !marker) return;
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
setError(null);
const dbId = marker.id.split("-").slice(1).join("-");
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
setIsLoading(true);
fetch(`${BACKEND_URL}/gas-station/markers/${dbId}`, { headers })
.then((r) => {
if (!r.ok) throw new Error("Gagal memuat data dari server");
return r.json();
})
.then((row) => {
setCategory(row.marker_category);
setName(row.name);
if (row.marker_category === "charging-station") {
setEvType(row.sub_type ?? "DC Fast");
setEvStatus(row.status ?? "Active");
} else if (row.marker_category === "gas-pump") {
setGasBrand(row.brand ?? "Pertamina");
setGasHours(row.operating_hours ?? "24 Jam");
setSelectedGasTypes(row.gas_types ?? GAS_TYPES_BY_BRAND[row.brand ?? "Pertamina"] ?? []);
} else if (row.marker_category === "wrench") {
setRepairSpeciality(row.sub_type ?? "Umum");
setRepairStatus(row.status ?? "Buka 08:00 - 17:00");
}
})
.catch((err) => {
console.error(err);
setError("Gagal memuat data terbaru, menggunakan data lokal.");
// Fallback
setCategory(marker.type);
setName(marker.name);
const meta = marker.meta;
if (marker.type === "charging-station") {
setEvType(meta?.poi_type ?? "DC Fast");
setEvStatus(meta?.notes?.replace("Status: ", "") ?? "Active");
} else if (marker.type === "gas-pump") {
setGasBrand(meta?.poi_type ?? "Pertamina");
setGasHours(meta?.notes?.replace("Jam: ", "") ?? "24 Jam");
setSelectedGasTypes(meta?.gas_types ?? GAS_TYPES_BY_BRAND[meta?.poi_type ?? "Pertamina"] ?? []);
} else if (marker.type === "wrench") {
setRepairSpeciality(meta?.poi_type ?? "Umum");
setRepairStatus(meta?.notes ?? "Buka 08:00 - 17:00");
}
})
.finally(() => setIsLoading(false));
}, [open, marker]);
async function handleDelete() {
const target = pendingDeleteMarker.current;
if (!target) return;
const dbId = target.id.split("-").slice(1).join("-");
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
setIsDeleting(true);
setError(null);
try {
const res = await fetch(`${BACKEND_URL}/gas-station/markers/${dbId}`, {
method: "DELETE",
headers,
});
if (!res.ok && res.status !== 204) {
throw new Error("Gagal menghapus lokasi dari server");
}
pendingDeleteMarker.current = null;
onDeleted(target);
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan saat menghapus");
toast.error(err instanceof Error ? err.message : "Gagal menghapus lokasi");
} finally {
setIsDeleting(false);
}
}
// Countdown tick
useEffect(() => {
if (!deleteActive) return;
const id = setInterval(() => setDeleteCountdown((p) => Math.max(0, p - 1)), 1000);
return () => clearInterval(id);
}, [deleteActive]);
// Fire delete when countdown hits 0
useEffect(() => {
if (!deleteActive || deleteCountdown > 0) return;
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
toast.dismiss(TOAST_ID);
handleDelete();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteActive, deleteCountdown]);
// Toast showing countdown
useEffect(() => {
if (!deleteActive || !pendingDeleteMarker.current) {
toast.dismiss(TOAST_ID);
return;
}
const mName = pendingDeleteMarker.current.name;
const pct = (deleteCountdown / DELETE_SECONDS) * 100;
toast.custom(
() => (
<div className="relative flex items-center gap-3 w-72 rounded-xl border border-border bg-popover px-4 py-3 shadow-lg overflow-hidden font-sans">
<div
className="absolute bottom-0 left-0 h-[3px] bg-red-500 transition-[width] duration-1000 ease-linear"
style={{ width: `${pct}%` }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate">Menghapus lokasi</p>
<p className="text-xs text-muted-foreground truncate">&quot;{mName}&quot; dalam {deleteCountdown} detik</p>
</div>
<button
onClick={() => {
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
}}
className="shrink-0 h-7 rounded-full border border-border bg-background px-3 text-xs font-medium hover:bg-muted transition-colors cursor-pointer"
>
Batalkan
</button>
</div>
),
{ id: TOAST_ID, duration: Infinity, position: "bottom-left" },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteActive, deleteCountdown]);
function toggleDelete() {
setDeleteActive((prev) => {
if (!prev) {
pendingDeleteMarker.current = marker;
setDeleteCountdown(DELETE_SECONDS);
} else {
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
}
return !prev;
});
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!marker) return;
setIsSubmitting(true);
setError(null);
let finalPoiType = "";
let finalNotes = "";
const brand = category === "gas-pump" ? gasBrand : null;
const sub_type = category === "charging-station" ? evType : (category === "wrench" ? repairSpeciality : null);
const gas_types = category === "gas-pump" ? selectedGasTypes : null;
const operating_hours = category === "gas-pump" ? gasHours : null;
const status = category === "charging-station" ? evStatus : (category === "wrench" ? repairStatus : null);
if (category === "charging-station") {
finalPoiType = evType;
finalNotes = `Status: ${evStatus}`;
} else if (category === "gas-pump") {
finalPoiType = gasBrand;
finalNotes = `Jam: ${gasHours}`;
} else if (category === "wrench") {
finalPoiType = repairSpeciality;
finalNotes = repairStatus;
}
try {
const dbId = marker.id.split("-").slice(1).join("-");
const token = localStorage.getItem("auth_token");
const headers = {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const res = await fetch(`${BACKEND_URL}/gas-station/markers/${dbId}`, {
method: "PUT",
headers,
body: JSON.stringify({
name: name.trim() || marker.name,
marker_category: category,
brand,
sub_type,
gas_types,
operating_hours,
status,
notes: null,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menyimpan perubahan");
}
const saved = await res.json();
const updated: UserMarker = {
...marker,
type: category,
name: saved.name,
meta: {
...marker.meta,
poi_type: finalPoiType,
notes: finalNotes,
gas_types: category === "gas-pump" ? selectedGasTypes : undefined,
},
};
onUpdated(updated);
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsSubmitting(false);
}
}
const categoryLabels: Record<string, string> = {
"charging-station": "Edit EV Charger",
"gas-pump": "Edit Gas Station",
wrench: "Edit Bengkel",
};
const titleLabel = categoryLabels[category] || "Edit Lokasi";
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onOpenChange(false); }}>
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
<form onSubmit={handleSubmit} className="flex flex-col">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">{titleLabel}</DialogTitle>
<p className="text-center text-xs text-muted-foreground font-mono tabular-nums mt-1">
{marker ? `${marker.lat.toFixed(5)}, ${marker.lng.toFixed(5)}` : ""}
</p>
</DialogHeader>
{/* Body */}
<div className="px-6 py-5 flex flex-col gap-5">
{isLoading ? (
<p className="text-sm text-muted-foreground text-center py-4">Memuat data</p>
) : (
<>
{/* Category Selector */}
<Field label="Kategori Lokasi">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full h-10 rounded-lg border border-border bg-muted/50 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring cursor-pointer"
>
<option value="charging-station">EV Charger</option>
<option value="gas-pump">Gas Station</option>
<option value="wrench">Repair Shop (Bengkel)</option>
</select>
</Field>
{/* Location Name */}
<Field label="Nama Lokasi">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
{/* Dynamic Fields: EV Charger */}
{category === "charging-station" && (
<>
<Field label="Tipe Charging">
<ChipSelector options={EV_TYPES} value={evType} onChange={setEvType} />
</Field>
<Field label="Status Operasional">
<ChipSelector options={EV_STATUSES} value={evStatus} onChange={setEvStatus} />
</Field>
</>
)}
{/* Dynamic Fields: Gas Station */}
{category === "gas-pump" && (
<>
<Field label="Brand SPBU">
<ChipSelector
options={GAS_BRANDS}
value={gasBrand}
onChange={(brand) => {
setGasBrand(brand);
setSelectedGasTypes(GAS_TYPES_BY_BRAND[brand] ?? []);
}}
/>
</Field>
<Field label="Tipe Bahan Bakar">
<MultiChipSelector
options={GAS_TYPES_BY_BRAND[gasBrand] ?? []}
values={selectedGasTypes}
onChange={setSelectedGasTypes}
color={GAS_BRANDS.find((b) => b.value === gasBrand)?.color ?? "#ff3b1f"}
/>
</Field>
<Field label="Jam Operasional">
<Input
value={gasHours}
onChange={(e) => setGasHours(e.target.value)}
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
<div className="flex gap-1.5 mt-1">
{["24 Jam", "06:00 - 22:00", "07:00 - 21:00"].map((h) => (
<button
key={h}
type="button"
onClick={() => setGasHours(h)}
className="text-[10px] bg-muted hover:bg-muted-foreground/20 text-muted-foreground px-2 py-0.5 rounded transition-colors cursor-pointer"
>
{h}
</button>
))}
</div>
</Field>
</>
)}
{/* Dynamic Fields: Repair Shop */}
{category === "wrench" && (
<>
<Field label="Spesialisasi">
<ChipSelector options={REPAIR_SPECIALITIES} value={repairSpeciality} onChange={setRepairSpeciality} />
</Field>
<Field label="Status / Jam Buka">
<Input
value={repairStatus}
onChange={(e) => setRepairStatus(e.target.value)}
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
<div className="flex gap-1.5 mt-1">
{["Buka 24 Jam", "Buka 08:00 - 17:00", "Tutup"].map((s) => (
<button
key={s}
type="button"
onClick={() => setRepairStatus(s)}
className="text-[10px] bg-muted hover:bg-muted-foreground/20 text-muted-foreground px-2 py-0.5 rounded transition-colors cursor-pointer"
>
{s}
</button>
))}
</div>
</Field>
</>
)}
</>
)}
{error && (
<p className="text-xs text-destructive mt-2">{error}</p>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-2 px-6 py-4 border-t">
<TimedUndoAction
compact
deleteLabel="Hapus Lokasi"
undoLabel="Batal"
isDeleting={deleteActive}
countDown={deleteCountdown}
onToggle={toggleDelete}
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => onOpenChange(false)}
disabled={isSubmitting || isDeleting}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50 cursor-pointer"
>
Batal
</button>
<button
type="submit"
disabled={isSubmitting || isLoading || isDeleting || !name.trim()}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40 cursor-pointer"
>
{isSubmitting ? "Menyimpan…" : "Simpan"}
</button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,449 @@
"use client";
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/set-state-in-effect */
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { UserMarker } from "@/components/leaflet-map";
/* ─── Selectors Options ────────────────────────── */
const EV_TYPES = [
{ value: "DC Fast", label: "DC Fast", color: "#10b981" },
{ value: "Supercharger", label: "Supercharger", color: "#14b8a6" },
{ value: "AC Level 2", label: "AC Level 2", color: "#3b82f6" },
];
const EV_STATUSES = [
{ value: "Active", label: "Active", color: "#10b981" },
{ value: "Maintenance", label: "Maintenance", color: "#eab308" },
{ value: "Offline", label: "Offline", color: "#ef4444" },
];
const GAS_BRANDS = [
{ value: "Pertamina", label: "Pertamina", color: "#ef4444" },
{ value: "Shell", label: "Shell", color: "#eab308" },
{ value: "BP", label: "BP", color: "#10b981" },
{ value: "Vivo", label: "Vivo", color: "#3b82f6" },
];
const GAS_TYPES_BY_BRAND: Record<string, string[]> = {
Pertamina: ["Pertalite", "Pertamax", "Pertamax Green", "Pertamax Turbo", "Dexlite", "Pertamina Dex", "Solar"],
Shell: ["Shell Super", "Shell V-Power", "Shell V-Power Nitro+", "Shell V-Power Diesel"],
BP: ["BP 92", "BP 95", "BP Ultimate", "BP Ultimate Diesel"],
Vivo: ["Revvo 90", "Revvo 92", "Revvo 95"],
};
const REPAIR_SPECIALITIES = [
{ value: "Umum", label: "Umum", color: "#6b7280" },
{ value: "AC & Kelistrikan",label: "AC & Kelistrikan",color: "#0ea5e9" },
{ value: "Mesin", label: "Mesin", color: "#f97316" },
{ value: "Ban & Velg", label: "Ban & Velg", color: "#f59e0b" },
];
/* ─── Helpers ───────────────────────────────────── */
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
function ChipSelector({
options,
value,
onChange,
}: {
options: { value: string; label: string; color: string }[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const isSelected = value === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
isSelected
? "border-transparent text-white shadow-sm animate-in fade-in zoom-in-95 duration-150"
: "border-border text-foreground/70 hover:bg-muted"
)}
style={isSelected ? { backgroundColor: opt.color } : undefined}
>
{opt.label}
</button>
);
})}
</div>
);
}
function MultiChipSelector({
options,
values,
onChange,
color,
}: {
options: string[];
values: string[];
onChange: (v: string[]) => void;
color: string;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const isSelected = values.includes(opt);
return (
<button
key={opt}
type="button"
onClick={() => {
if (isSelected) {
onChange(values.filter((v) => v !== opt));
} else {
onChange([...values, opt]);
}
}}
className={cn(
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
isSelected
? "border-transparent text-white shadow-sm"
: "border-border text-foreground/70 hover:bg-muted"
)}
style={isSelected ? { backgroundColor: color } : undefined}
>
{opt}
</button>
);
})}
</div>
);
}
/* ─── Placement Dialog ─────────────────────────── */
interface StationPlacementDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pendingClick: { lat: number; lng: number } | null;
markerType: string | null;
onConfirm: (marker: UserMarker) => void;
}
export function StationPlacementDialog({
open,
onOpenChange,
pendingClick,
markerType,
onConfirm,
}: StationPlacementDialogProps) {
const [category, setCategory] = useState("gas-pump");
const [name, setName] = useState("");
// Form fields for EV Charger
const [evType, setEvType] = useState("DC Fast");
const [evStatus, setEvStatus] = useState("Active");
// Form fields for Gas Station
const [gasBrand, setGasBrand] = useState("Pertamina");
const [gasHours, setGasHours] = useState("24 Jam");
const [selectedGasTypes, setSelectedGasTypes] = useState<string[]>([]);
// Form fields for Repair Shop
const [repairSpeciality, setRepairSpeciality] = useState("Umum");
const [repairStatus, setRepairStatus] = useState("Buka 08:00 - 17:00");
// Submission state
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
// Synchronize with initial markerType when dialog opens
useEffect(() => {
if (open && markerType) {
setCategory(markerType);
setName("");
setEvType("DC Fast");
setEvStatus("Active");
setGasBrand("Pertamina");
setGasHours("24 Jam");
setSelectedGasTypes(GAS_TYPES_BY_BRAND["Pertamina"]);
setRepairSpeciality("Umum");
setRepairStatus("Buka 08:00 - 17:00");
setError(null);
setIsSubmitting(false);
}
}, [open, markerType]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!pendingClick) return;
setIsSubmitting(true);
setError(null);
let finalPoiType = "";
let finalNotes = "";
const brand = category === "gas-pump" ? gasBrand : null;
const sub_type = category === "charging-station" ? evType : (category === "wrench" ? repairSpeciality : null);
const gas_types = category === "gas-pump" ? selectedGasTypes : null;
const operating_hours = category === "gas-pump" ? gasHours : null;
const status = category === "charging-station" ? evStatus : (category === "wrench" ? repairStatus : null);
if (category === "charging-station") {
finalPoiType = evType;
finalNotes = `Status: ${evStatus}`;
} else if (category === "gas-pump") {
finalPoiType = gasBrand;
finalNotes = `Jam: ${gasHours}`;
} else if (category === "wrench") {
finalPoiType = repairSpeciality;
finalNotes = repairStatus;
}
try {
const token = localStorage.getItem("auth_token");
const res = await fetch(`${BACKEND_URL}/gas-station/markers`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
name: name.trim() || `New ${category.replace("-", " ")}`,
marker_category: category,
brand,
sub_type,
gas_types,
operating_hours,
status,
latitude: pendingClick.lat,
longitude: pendingClick.lng,
notes: null,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menyimpan data");
}
const saved = await res.json();
const savedMarker: UserMarker = {
id: `gsm-${saved.id}`,
lat: pendingClick.lat,
lng: pendingClick.lng,
type: category,
name: saved.name,
meta: {
poi_type: finalPoiType,
notes: finalNotes,
gas_types: category === "gas-pump" ? selectedGasTypes : undefined,
},
};
onConfirm(savedMarker);
handleCancel();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsSubmitting(false);
}
}
function handleCancel() {
setName("");
setError(null);
setIsSubmitting(false);
onOpenChange(false);
}
const categoryLabels: Record<string, string> = {
"charging-station": "EV Charger Baru",
"gas-pump": "Gas Station Baru",
wrench: "Bengkel Baru",
};
const titleLabel = categoryLabels[category] || "Lokasi Baru";
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
<form onSubmit={handleSubmit} className="flex flex-col">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">{titleLabel}</DialogTitle>
<p className="text-center text-xs text-muted-foreground font-mono tabular-nums mt-1">
{pendingClick ? `${pendingClick.lat.toFixed(5)}, ${pendingClick.lng.toFixed(5)}` : ""}
</p>
</DialogHeader>
{/* Body */}
<div className="px-6 py-5 flex flex-col gap-5">
{/* Category Selector */}
<Field label="Kategori Lokasi">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full h-10 rounded-lg border border-border bg-muted/50 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring cursor-pointer"
>
<option value="charging-station">EV Charger</option>
<option value="gas-pump">Gas Station</option>
<option value="wrench">Repair Shop (Bengkel)</option>
</select>
</Field>
{/* Location Name */}
<Field label="Nama Lokasi">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={
category === "charging-station"
? "Contoh: VoltCharge EV Station"
: category === "gas-pump"
? "Contoh: EcoFuel Station Menteng"
: "Contoh: Bengkel Motor Jaya"
}
required
autoFocus
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
{/* Dynamic Fields: EV Charger */}
{category === "charging-station" && (
<>
<Field label="Tipe Charging">
<ChipSelector options={EV_TYPES} value={evType} onChange={setEvType} />
</Field>
<Field label="Status Operasional">
<ChipSelector options={EV_STATUSES} value={evStatus} onChange={setEvStatus} />
</Field>
</>
)}
{/* Dynamic Fields: Gas Station */}
{category === "gas-pump" && (
<>
<Field label="Brand SPBU">
<ChipSelector
options={GAS_BRANDS}
value={gasBrand}
onChange={(brand) => {
setGasBrand(brand);
setSelectedGasTypes(GAS_TYPES_BY_BRAND[brand] ?? []);
}}
/>
</Field>
<Field label="Tipe Bahan Bakar">
<MultiChipSelector
options={GAS_TYPES_BY_BRAND[gasBrand] ?? []}
values={selectedGasTypes}
onChange={setSelectedGasTypes}
color={GAS_BRANDS.find((b) => b.value === gasBrand)?.color ?? "#ff3b1f"}
/>
</Field>
<Field label="Jam Operasional">
<Input
value={gasHours}
onChange={(e) => setGasHours(e.target.value)}
placeholder="Contoh: 24 Jam atau 06:00 - 22:00"
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
<div className="flex gap-1.5 mt-1">
{["24 Jam", "06:00 - 22:00", "07:00 - 21:00"].map((h) => (
<button
key={h}
type="button"
onClick={() => setGasHours(h)}
className="text-[10px] bg-muted hover:bg-muted-foreground/20 text-muted-foreground px-2 py-0.5 rounded transition-colors cursor-pointer"
>
{h}
</button>
))}
</div>
</Field>
</>
)}
{/* Dynamic Fields: Repair Shop */}
{category === "wrench" && (
<>
<Field label="Spesialisasi">
<ChipSelector options={REPAIR_SPECIALITIES} value={repairSpeciality} onChange={setRepairSpeciality} />
</Field>
<Field label="Status / Jam Buka">
<Input
value={repairStatus}
onChange={(e) => setRepairStatus(e.target.value)}
placeholder="Contoh: Buka 08:00 - 17:00 atau Tutup"
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
<div className="flex gap-1.5 mt-1">
{["Buka 24 Jam", "Buka 08:00 - 17:00", "Tutup"].map((s) => (
<button
key={s}
type="button"
onClick={() => setRepairStatus(s)}
className="text-[10px] bg-muted hover:bg-muted-foreground/20 text-muted-foreground px-2 py-0.5 rounded transition-colors cursor-pointer"
>
{s}
</button>
))}
</div>
</Field>
</>
)}
{error && (
<p className="text-xs text-destructive mt-2">{error}</p>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 px-6 py-4 border-t">
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50 cursor-pointer"
>
Batal
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim()}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40 cursor-pointer"
>
{isSubmitting ? "Menyimpan…" : "Simpan & Pasang"}
</button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
export { EV_TYPES, EV_STATUSES, GAS_BRANDS, REPAIR_SPECIALITIES, GAS_TYPES_BY_BRAND };
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/logo";
import { useScroll } from "@/hooks/use-scroll";
import { Button } from "@/components/ui/button";
import { MobileNav } from "@/components/mobile-nav";
export const navLinks = [
{
label: "Features",
href: "#",
},
{
label: "Pricing",
href: "#",
},
{
label: "About",
href: "#",
},
];
export function Header() {
const scrolled = useScroll(10);
return (
<header
className={cn(
"sticky top-0 z-50 mx-auto w-full max-w-4xl border-transparent border-b md:rounded-md md:border md:transition-all md:ease-out",
{
"border-border bg-background/95 backdrop-blur-sm supports-backdrop-filter:bg-background/50 md:top-2 md:max-w-3xl md:shadow":
scrolled,
}
)}
>
<nav
className={cn(
"flex h-14 w-full items-center justify-between px-4 md:h-12 md:transition-all md:ease-out",
{
"md:px-2": scrolled,
}
)}
>
<a
className="rounded-md p-2 hover:bg-muted dark:hover:bg-muted/50"
href="#"
>
<Logo className="h-4" />
</a>
<div className="hidden items-center gap-2 md:flex">
<div>
{navLinks.map((link) => (
<Button key={link.label} size="sm" variant="ghost" render={<a href={link.href} />} nativeButton={false}>{link.label}</Button>
))}
</div>
<Button size="sm" variant="outline">
Sign In
</Button>
<Button size="sm">Get Started</Button>
</div>
<MobileNav />
</nav>
</header>
);
}
@@ -0,0 +1,128 @@
"use client";
import type * as React from "react";
import { Bar, BarChart, XAxis } from "recharts";
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { Delta, DeltaIcon, DeltaValue } from "@/components/delta";
import { DashboardCard } from "@/components/dashboard-card";
import { formatDate } from "@/components/formater";
import type { PovertyOverview } from "@/lib/poverty-types";
interface HouseholdsTrendChartProps {
monthly: PovertyOverview["monthly"];
}
const chartConfig = {
count: {
label: "KK Terdata",
color: "var(--chart-2)",
},
} satisfies ChartConfig;
function CustomGradientBar(
props: React.SVGProps<SVGRectElement> & {
index?: number;
dataKey?: string | number;
}
) {
const {
fill,
x = 0,
y = 0,
width = 0,
height = 0,
dataKey = "count",
index = 0,
} = props;
const gid = `gradient-bar-${String(dataKey)}-${index}`;
return (
<>
<rect
fill={`url(#${gid})`}
height={height}
stroke="none"
width={width}
x={x}
y={y}
/>
<rect fill={fill} height={2} stroke="none" width={width} x={x} y={y} />
<defs>
<linearGradient id={gid} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={fill} stopOpacity={0.5} />
<stop offset="100%" stopColor={fill} stopOpacity={0} />
</linearGradient>
</defs>
</>
);
}
export function HouseholdsTrendChart({ monthly }: HouseholdsTrendChartProps) {
const chartRows = monthly.map((m) => ({ month: m.month, count: m.count }));
const first = chartRows[0]?.count ?? 0;
const last = chartRows.at(-1)?.count ?? 0;
const growthPct = first > 0 ? ((last - first) / first) * 100 : 0;
return (
<DashboardCard className="gap-0 md:col-span-2">
<CardHeader className="gap-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle>Pendataan Warga</CardTitle>
{chartRows.length > 1 && (
<Delta value={Number(growthPct.toFixed(1))} variant="badge">
<DeltaIcon variant="trend" />
<DeltaValue />
</Delta>
)}
</div>
<CardDescription>
KK baru terdata per bulan, 12 bulan terakhir.
</CardDescription>
</CardHeader>
<CardContent>
{chartRows.length === 0 ? (
<div className="flex h-60 w-full items-center justify-center text-xs text-muted-foreground md:h-80">
Belum ada data pendataan warga.
</div>
) : (
<ChartContainer
className="aspect-auto h-60 w-full md:h-80"
config={chartConfig}
>
<BarChart accessibilityLayer data={chartRows}>
<XAxis
axisLine={false}
dataKey="month"
interval={0}
tickFormatter={(value) => formatDate(`${value}-01`, "month")}
tickLine={false}
tickMargin={10}
/>
<ChartTooltip
content={<ChartTooltipContent hideLabel />}
cursor={false}
/>
<Bar
dataKey="count"
fill="var(--color-count)"
shape={<CustomGradientBar />}
/>
</BarChart>
</ChartContainer>
)}
</CardContent>
</DashboardCard>
);
}
+18
View File
@@ -0,0 +1,18 @@
export function AppleIcon({
fill = "currentColor",
...props
}: React.ComponentProps<"svg">) {
return (
<svg
fill={fill}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g>
<path d="M18.546,12.763c0.024-1.87,1.004-3.597,2.597-4.576c-1.009-1.442-2.64-2.323-4.399-2.378 c-1.851-0.194-3.645,1.107-4.588,1.107c-0.961,0-2.413-1.088-3.977-1.056C6.122,5.927,4.25,7.068,3.249,8.867 c-2.131,3.69-0.542,9.114,1.5,12.097c1.022,1.461,2.215,3.092,3.778,3.035c1.529-0.063,2.1-0.975,3.945-0.975 c1.828,0,2.364,0.975,3.958,0.938c1.64-0.027,2.674-1.467,3.66-2.942c0.734-1.041,1.299-2.191,1.673-3.408 C19.815,16.788,18.548,14.879,18.546,12.763z" />
<path d="M15.535,3.847C16.429,2.773,16.87,1.393,16.763,0c-1.366,0.144-2.629,0.797-3.535,1.829 c-0.895,1.019-1.349,2.351-1.261,3.705C13.352,5.548,14.667,4.926,15.535,3.847z" />
</g>
</svg>
);
}
+10
View File
@@ -0,0 +1,10 @@
export function GithubIcon(props: React.ComponentProps<"svg">) {
return (
<svg viewBox="0 0 438.549 438.549" {...props}>
<path
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
fill="currentColor"
/>
</svg>
);
}
+14
View File
@@ -0,0 +1,14 @@
export function GoogleIcon(props: React.ComponentProps<"svg">) {
return (
<svg
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g>
<path d="M12.479,14.265v-3.279h11.049c0.108,0.571,0.164,1.247,0.164,1.979c0,2.46-0.672,5.502-2.84,7.669C18.744,22.829,16.051,24,12.483,24C5.869,24,0.308,18.613,0.308,12S5.869,0,12.483,0c3.659,0,6.265,1.436,8.223,3.307L18.392,5.62c-1.404-1.317-3.307-2.341-5.913-2.341C7.65,3.279,3.873,7.171,3.873,12s3.777,8.721,8.606,8.721c3.132,0,4.916-1.258,6.059-2.401c0.927-0.927,1.537-2.251,1.777-4.059L12.479,14.265z" />
</g>
</svg>
);
}
@@ -0,0 +1,79 @@
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
interface LandAllocationChartProps {
pieChartData: Array<{
name: string;
value: number;
color: string;
}>;
}
export function LandAllocationChart({ pieChartData }: LandAllocationChartProps) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
<CardHeader>
<CardTitle className="text-base font-semibold font-sans">Land Status Breakdown</CardTitle>
<CardDescription>Acreage and share representation of registered parcels by ownership status.</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col sm:flex-row items-center justify-around gap-6 py-6 min-h-[260px]">
{pieChartData.length > 0 ? (
<>
<div className="h-56 w-56 shrink-0">
{!isMounted ? (
<div className="h-full w-full" />
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieChartData}
cx="50%"
cy="50%"
innerRadius={65}
outerRadius={85}
paddingAngle={3}
dataKey="value"
>
{pieChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
background: "rgba(12, 10, 8, 0.95)",
border: "1px solid #4d505d",
borderRadius: "6px",
fontSize: "12px",
color: "#ffffff"
}}
/>
</PieChart>
</ResponsiveContainer>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-xs w-full max-w-sm">
{pieChartData.map((d, index) => (
<div key={index} className="flex items-center gap-2 border p-2 rounded bg-background">
<div className="h-3 w-3 rounded shrink-0" style={{ backgroundColor: d.color }} />
<div className="flex flex-col min-w-0">
<span className="font-semibold text-foreground truncate">{d.name}</span>
<span className="text-[10px] text-muted-foreground">{d.value} Hectares</span>
</div>
</div>
))}
</div>
</>
) : (
<p className="text-center text-xs text-muted-foreground py-12">Belum ada data poligon lahan.</p>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,96 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { SearchIcon } from "@/components/ui/phosphor-icons";
import { LAND_MARKER_LABELS, LAND_MARKER_COLORS } from "@/lib/map-data";
import type { UserMarker } from "@/components/leaflet-map";
import { calcLineLength, calcPolygonArea } from "@/components/land/shape-placement-dialog";
interface LandDirectoryProps {
searchQuery: string;
setSearchQuery: (query: string) => void;
filteredItems: UserMarker[];
selectedItemId: string | null;
visibilityFilters: Record<string, boolean>;
onSelectItem: (item: UserMarker) => void;
}
function itemBadge(item: UserMarker): string {
const coordinates = item.meta?.coordinates ?? [];
if (item.type === "polygon" && coordinates.length > 0) {
return `${(calcPolygonArea(coordinates) / 10000).toFixed(2)} Ha`;
}
if (item.type === "line" && coordinates.length > 0) {
const length = calcLineLength(coordinates);
return length >= 1000 ? `${(length / 1000).toFixed(2)} km` : `${length.toFixed(1)} m`;
}
if (item.type === "circle") {
const radius = item.meta?.radius ?? 0;
return `${radius.toFixed(1)} m`;
}
return LAND_MARKER_LABELS[item.type] ?? item.type;
}
export function LandDirectory({
searchQuery,
setSearchQuery,
filteredItems,
selectedItemId,
visibilityFilters,
onSelectItem,
}: LandDirectoryProps) {
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
<CardHeader className="py-4">
<CardTitle className="text-base font-semibold">GIS Layer Directory</CardTitle>
<CardDescription>Select a marker or shape to review survey metadata and inspector details.</CardDescription>
</CardHeader>
<CardContent className="pb-6 flex-1">
<div className="relative mb-4">
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by name or type..."
className="pl-9 bg-background h-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{filteredItems.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
{filteredItems.map((item) => {
const isSelected = selectedItemId === item.id;
const isVisible = visibilityFilters[item.type] !== false;
const color = LAND_MARKER_COLORS[item.type] ?? "#6b7280";
return (
<div
key={item.id}
onClick={() => onSelectItem(item)}
className={`p-3 border rounded-lg cursor-pointer flex justify-between items-center gap-2 transition-all ${
isSelected
? "border-primary bg-primary/5 text-primary font-semibold"
: "border-border/60 hover:bg-muted"
} ${!isVisible ? "opacity-45" : ""}`}
>
<span className="text-xs text-foreground font-medium truncate">{item.name}</span>
<span
className="text-[9px] border px-2 py-0.5 rounded font-bold shrink-0"
style={{
color,
borderColor: `${color}33`,
backgroundColor: `${color}11`
}}
>
{itemBadge(item)}
</span>
</div>
);
})}
</div>
) : (
<p className="text-center text-xs text-muted-foreground py-12">
Belum ada penanda atau bentuk yang ditambahkan. Buka peta untuk mulai menambahkan.
</p>
)}
</CardContent>
</Card>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { LayersIcon, EyeIcon, EyeOffIcon, ShieldAlertIcon } from "@/components/ui/phosphor-icons";
import { LAND_MARKER_LABELS, LAND_MARKER_COLORS } from "@/lib/map-data";
import type { UserMarker } from "@/components/leaflet-map";
import { calcLineLength, calcPolygonArea } from "@/components/land/shape-placement-dialog";
function formatDate(iso?: string): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("id-ID", { year: "numeric", month: "short", day: "numeric" });
}
interface LandInspectorTopProps {
selectedItem: UserMarker | null;
visibilityFilters: Record<string, boolean>;
toggleFilter: (type: string) => void;
}
export function LandInspectorTop({ selectedItem, visibilityFilters, toggleFilter }: LandInspectorTopProps) {
if (!selectedItem) {
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<CardHeader className="border-b bg-muted/20 px-6 py-4 flex flex-col justify-center">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<LayersIcon className="h-4 w-4 text-primary" />
Boundary Inspector
</CardTitle>
<CardDescription>Reviewing border registries.</CardDescription>
</CardHeader>
<CardContent className="p-6 flex-1 flex items-center justify-center">
<p className="text-center text-xs text-muted-foreground">
Belum ada item dipilih. Buka peta atau pilih item dari direktori.
</p>
</CardContent>
</Card>
);
}
const color = LAND_MARKER_COLORS[selectedItem.type] ?? "#6b7280";
const label = LAND_MARKER_LABELS[selectedItem.type] ?? selectedItem.type;
const coordinates = selectedItem.meta?.coordinates ?? [];
const radius = selectedItem.meta?.radius ?? 0;
const length = selectedItem.type === "line" ? calcLineLength(coordinates) : 0;
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<CardHeader className="border-b bg-muted/20 px-6 py-4 flex flex-col justify-center">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<LayersIcon className="h-4 w-4 text-primary" />
Boundary Inspector
</CardTitle>
<CardDescription>Reviewing border registries.</CardDescription>
</CardHeader>
<CardContent className="p-6 flex flex-col gap-5 flex-1 justify-center">
<div>
<h3 className="text-xl font-bold text-foreground">{selectedItem.name}</h3>
<span
className="inline-block mt-1.5 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border"
style={{
color,
borderColor: `${color}33`,
backgroundColor: `${color}11`
}}
>
{label}
</span>
</div>
<div className="flex flex-col gap-2.5 text-xs py-3 border-y">
{selectedItem.type === "polygon" && coordinates.length > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Luas Terdaftar:</span>
<span className="font-semibold text-foreground">{(calcPolygonArea(coordinates) / 10000).toFixed(2)} Ha</span>
</div>
)}
{selectedItem.type === "line" && coordinates.length > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Panjang:</span>
<span className="font-semibold text-foreground">
{length >= 1000 ? `${(length / 1000).toFixed(2)} km` : `${length.toFixed(1)} m`}
</span>
</div>
)}
{selectedItem.type === "circle" && (
<div className="flex justify-between">
<span className="text-muted-foreground">Radius:</span>
<span className="font-semibold text-foreground">{radius.toFixed(1)} m</span>
</div>
)}
{(selectedItem.type === "line" || selectedItem.type === "polygon") && selectedItem.meta?.poi_type && (
<div className="flex justify-between">
<span className="text-muted-foreground">Status:</span>
<span className="font-semibold text-foreground">{selectedItem.meta.poi_type}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Created By:</span>
<span className="font-semibold text-foreground text-right max-w-44 truncate">{selectedItem.meta?.created_by_username ?? "—"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Created:</span>
<span className="font-semibold text-foreground">{formatDate(selectedItem.meta?.created_at)}</span>
</div>
</div>
<div className="flex flex-col gap-2 justify-center py-2">
<span className="text-[10px] font-bold text-muted-foreground uppercase">Visibility filter status:</span>
<div className="flex items-center gap-2 text-xs">
<Button
variant="outline"
size="sm"
onClick={() => toggleFilter(selectedItem.type)}
className="h-8 gap-1 text-xs"
>
{visibilityFilters[selectedItem.type] !== false ? (
<>
<EyeOffIcon className="h-3.5 w-3.5" />
Hide Category
</>
) : (
<>
<EyeIcon className="h-3.5 w-3.5" />
Show Category
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
interface LandInspectorBottomProps {
recentFlags: UserMarker[];
}
export function LandInspectorBottom({ recentFlags }: LandInspectorBottomProps) {
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col justify-between">
<CardHeader className="py-3 px-6 border-b bg-muted/10">
<CardTitle className="text-xs font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<ShieldAlertIcon className="h-3.5 w-3.5 text-primary" />
Surveyor Logs
</CardTitle>
</CardHeader>
<CardContent className="p-4 flex flex-col gap-2 flex-1">
{recentFlags.length > 0 ? (
recentFlags.map((flag) => (
<div key={flag.id} className="p-2 border rounded bg-background text-[11px] flex flex-col gap-1">
<div className="flex justify-between font-bold text-primary">
<span className="truncate">{flag.name}</span>
</div>
{flag.meta?.notes && (
<p className="text-muted-foreground">{flag.meta.notes}</p>
)}
<span className="text-[9px] text-muted-foreground/60">Reported: {formatDate(flag.meta?.created_at)}</span>
</div>
))
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-center text-xs text-muted-foreground py-6">Belum ada tengara lahan tercatat.</p>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,55 @@
import { Card } from "@/components/ui/card";
import { LayersIcon, RoadHorizonIcon, MapPinIcon } from "@/components/ui/phosphor-icons";
interface LandStatsCardsProps {
aggregateStats: {
totalParcelArea: string;
totalRoadLength: string;
activeMarkers: number;
};
}
export function LandStatsCards({ aggregateStats }: LandStatsCardsProps) {
return (
<>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<div className="px-6 pt-6 pb-4">
<div className="flex justify-between items-center pb-2">
<span className="text-xs font-medium text-muted-foreground">Total Parcel Area</span>
<LayersIcon className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-3xl font-bold text-foreground mt-2">{aggregateStats.totalParcelArea} Ha</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
Sum of all registered polygon parcels
</div>
</Card>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<div className="px-6 pt-6 pb-4">
<div className="flex justify-between items-center pb-2">
<span className="text-xs font-medium text-muted-foreground">Total Road Length</span>
<RoadHorizonIcon className="h-4 w-4 text-cobalt-glow" />
</div>
<div className="text-3xl font-bold text-foreground mt-2">{aggregateStats.totalRoadLength} km</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
Sum of all measured road/line segments
</div>
</Card>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<div className="px-6 pt-6 pb-4">
<div className="flex justify-between items-center pb-2">
<span className="text-xs font-medium text-muted-foreground">Active Markers</span>
<MapPinIcon className="h-4 w-4 text-primary" />
</div>
<div className="text-3xl font-bold text-foreground mt-2">{aggregateStats.activeMarkers} Markers</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
Land pins, survey flags, protected zones &amp; registries
</div>
</Card>
</>
);
}
@@ -0,0 +1,338 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { UserMarker } from "@/components/leaflet-map";
import { TimedUndoAction } from "@/components/time-undo-action";
import {
ROAD_STATUSES,
LAND_STATUSES,
calcLineLength,
calcPolygonArea,
} from "@/components/land/shape-placement-dialog";
import { LAND_MARKER_LABELS } from "@/lib/map-data";
/* ─── Shared Field component ─────────────────────────────── */
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
/* ─── Stat Chip ──────────────────────────────────────────── */
function StatChip({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2">
<span className="text-[10px] font-semibold text-foreground/50 uppercase tracking-wider">{label}</span>
<span className="text-sm font-semibold text-foreground tabular-nums ml-auto">{value}</span>
</div>
);
}
/* ─── Status Chip Selector ───────────────────────────────── */
function StatusSelector({
options,
value,
onChange,
}: {
options: { value: string; label: string; color: string }[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const isSelected = value === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all",
isSelected
? "border-transparent text-white shadow-sm"
: "border-border text-foreground/70 hover:bg-muted"
)}
style={isSelected ? { backgroundColor: opt.color } : undefined}
>
{opt.label}
</button>
);
})}
</div>
);
}
/* ─── Dialog Component ───────────────────────────────────── */
interface ShapeEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
shape: UserMarker | null;
onUpdated: (updated: UserMarker) => void;
onDeleted: (deleted: UserMarker) => void;
}
export function ShapeEditDialog({
open,
onOpenChange,
shape,
onUpdated,
onDeleted,
}: ShapeEditDialogProps) {
const [name, setName] = useState("");
const [status, setStatus] = useState("");
const [notes, setNotes] = useState("");
const DELETE_SECONDS = 5;
const TOAST_ID = "shape-delete";
const [deleteActive, setDeleteActive] = useState(false);
const [deleteCountdown, setDeleteCountdown] = useState(DELETE_SECONDS);
const pendingDeleteShape = useRef<UserMarker | null>(null);
const shapeType = shape?.type ?? "";
const isLine = shapeType === "line";
const isPolygon = shapeType === "polygon";
const isCircle = shapeType === "circle";
const titleLabel = `Edit ${LAND_MARKER_LABELS[shapeType] ?? "Item"}`;
const coordinates = shape?.meta?.coordinates ?? [];
const radius = shape?.meta?.radius ?? 0;
const length = isLine ? calcLineLength(coordinates) : 0;
const area = isPolygon
? calcPolygonArea(coordinates)
: isCircle
? Math.PI * radius * radius
: 0;
const statusOptions = isLine ? ROAD_STATUSES : isPolygon ? LAND_STATUSES : [];
// Reset when different shape opens
useEffect(() => {
if (!shape?.id) return;
setName(shape.name);
setStatus(shape.meta?.poi_type ?? (isLine ? "Regency Road" : isPolygon ? "SHM" : ""));
setNotes(shape.meta?.notes ?? "");
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
pendingDeleteShape.current = null;
toast.dismiss(TOAST_ID);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shape?.id]);
// Countdown tick
useEffect(() => {
if (!deleteActive) return;
const id = setInterval(() => setDeleteCountdown((p) => Math.max(0, p - 1)), 1000);
return () => clearInterval(id);
}, [deleteActive]);
// Fire delete when countdown hits 0
useEffect(() => {
if (!deleteActive || deleteCountdown > 0) return;
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
toast.dismiss(TOAST_ID);
handleDelete();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteActive, deleteCountdown]);
// Toast while countdown is running
useEffect(() => {
if (!deleteActive || !pendingDeleteShape.current) {
toast.dismiss(TOAST_ID);
return;
}
const shapeName = pendingDeleteShape.current.name;
const pct = (deleteCountdown / DELETE_SECONDS) * 100;
toast.custom(
() => (
<div className="relative flex items-center gap-3 w-72 rounded-xl border border-border bg-popover px-4 py-3 shadow-lg overflow-hidden font-sans">
<div
className="absolute bottom-0 left-0 h-[3px] bg-red-500 transition-[width] duration-1000 ease-linear"
style={{ width: `${pct}%` }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate">Menghapus shape</p>
<p className="text-xs text-muted-foreground truncate">&quot;{shapeName}&quot; dalam {deleteCountdown} detik</p>
</div>
<button
onClick={() => {
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
pendingDeleteShape.current = null;
toast.dismiss(TOAST_ID);
}}
className="shrink-0 h-7 rounded-full border border-border bg-background px-3 text-xs font-medium hover:bg-muted transition-colors cursor-pointer"
>
Batalkan
</button>
</div>
),
{ id: TOAST_ID, duration: Infinity, position: "bottom-left" },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteActive, deleteCountdown]);
function toggleDelete() {
setDeleteActive((prev) => {
if (!prev) {
pendingDeleteShape.current = shape;
setDeleteCountdown(DELETE_SECONDS);
} else {
pendingDeleteShape.current = null;
toast.dismiss(TOAST_ID);
}
return !prev;
});
}
function handleDelete() {
const target = pendingDeleteShape.current;
if (!target) return;
pendingDeleteShape.current = null;
onDeleted(target);
onOpenChange(false);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!shape) return;
const updated: UserMarker = {
...shape,
name: name.trim() || shape.name,
meta: {
...shape.meta,
poi_type: status || undefined,
notes: notes.trim() || undefined,
},
};
onUpdated(updated);
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onOpenChange(false); }}>
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
<form onSubmit={handleSubmit} className="flex flex-col">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">{titleLabel}</DialogTitle>
<p className="text-center text-xs text-muted-foreground font-mono tabular-nums mt-1">
{shape ? `${shape.lat.toFixed(5)}, ${shape.lng.toFixed(5)}` : ""}
</p>
</DialogHeader>
{/* Body */}
<div className="px-6 py-5 flex flex-col gap-5">
{/* Name */}
<Field label={isLine ? "Nama Jalan" : isPolygon ? "Nama Lahan" : isCircle ? "Nama Area" : "Nama Penanda"}>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
{/* Notes (optional, all types) */}
<Field label="Catatan">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Catatan tambahan (opsional)"
rows={2}
className="w-full min-w-0 rounded-lg border border-input bg-muted/50 px-2.5 py-1.5 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 resize-none"
/>
</Field>
{/* Status (line / polygon only) */}
{statusOptions.length > 0 && (
<Field label="Status">
<StatusSelector options={statusOptions} value={status} onChange={setStatus} />
</Field>
)}
{/* Auto-calculated metrics */}
<div className={cn("grid gap-3", isCircle ? "grid-cols-2" : "grid-cols-1")}>
{isLine && (
<StatChip
label="Panjang"
value={length >= 1000
? `${(length / 1000).toFixed(2)} km`
: `${length.toFixed(1)} m`}
/>
)}
{(isPolygon || isCircle) && (
<StatChip
label="Luas"
value={area >= 10000
? `${(area / 10000).toFixed(2)} ha`
: `${area.toFixed(1)}`}
/>
)}
{isCircle && (
<StatChip label="Radius" value={`${radius.toFixed(1)} m`} />
)}
</div>
{(isLine || isPolygon) && coordinates.length > 0 && (
<p className="text-[10px] text-muted-foreground">
{coordinates.length} titik koordinat
</p>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-2 px-6 py-4 border-t">
<TimedUndoAction
compact
deleteLabel="Hapus Shape"
undoLabel="Batal"
isDeleting={deleteActive}
countDown={deleteCountdown}
onToggle={toggleDelete}
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => onOpenChange(false)}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted"
>
Batal
</button>
<button
type="submit"
disabled={!name.trim()}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40"
>
Simpan
</button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,306 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { UserMarker } from "@/components/leaflet-map";
import { LAND_MARKER_LABELS } from "@/lib/map-data";
/* ─── Status options per shape type ──────────────────────── */
const ROAD_STATUSES = [
{ value: "National Road", label: "National", color: "#ef4444" },
{ value: "Provincial Road", label: "Provincial", color: "#eab308" },
{ value: "Regency Road", label: "Regency", color: "#22c55e" },
];
const LAND_STATUSES = [
{ value: "SHM", label: "SHM", color: "#a855f7" },
{ value: "HGB", label: "HGB", color: "#f97316" },
{ value: "HGU", label: "HGU", color: "#14b8a6" },
{ value: "HP", label: "HP", color: "#ec4899" },
];
/* ─── Geometry helpers ───────────────────────────────────── */
/** Calculate polyline length from [lat, lng][] coordinates in meters */
function calcLineLength(coords: [number, number][]): number {
let total = 0;
for (let i = 1; i < coords.length; i++) {
total += haversineDistance(coords[i - 1], coords[i]);
}
return total;
}
/** Calculate polygon area from [lat, lng][] using shoelfoot formula + spherical correction */
function calcPolygonArea(coords: [number, number][]): number {
// Shoelace formula on projected coords (approximate for small areas)
const R = 6371000; // Earth radius in meters
let area = 0;
const n = coords.length;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
const lat1 = coords[i][0] * Math.PI / 180;
const lat2 = coords[j][0] * Math.PI / 180;
const lng1 = coords[i][1] * Math.PI / 180;
const lng2 = coords[j][1] * Math.PI / 180;
area += (lng2 - lng1) * (2 + Math.sin(lat1) + Math.sin(lat2));
}
area = Math.abs(area * R * R / 2);
return area;
}
/** Haversine distance between two [lat, lng] points in meters */
function haversineDistance(a: [number, number], b: [number, number]): number {
const R = 6371000;
const dLat = (b[0] - a[0]) * Math.PI / 180;
const dLng = (b[1] - a[1]) * Math.PI / 180;
const lat1 = a[0] * Math.PI / 180;
const lat2 = b[0] * Math.PI / 180;
const s = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
}
/* ─── Shared Field component ─────────────────────────────── */
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
/* ─── Stat Chip (for read-only auto-calculated values) ──── */
function StatChip({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2">
<span className="text-[10px] font-semibold text-foreground/50 uppercase tracking-wider">{label}</span>
<span className="text-sm font-semibold text-foreground tabular-nums ml-auto">{value}</span>
</div>
);
}
/* ─── Status Chip Selector ───────────────────────────────── */
function StatusSelector({
options,
value,
onChange,
}: {
options: { value: string; label: string; color: string }[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const isSelected = value === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"h-8 rounded-lg border px-3 text-[11px] font-semibold transition-all",
isSelected
? "border-transparent text-white shadow-sm"
: "border-border text-foreground/70 hover:bg-muted"
)}
style={isSelected ? { backgroundColor: opt.color } : undefined}
>
{opt.label}
</button>
);
})}
</div>
);
}
/* ─── Dialog Component ───────────────────────────────────── */
interface ShapePlacementDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pendingShape: UserMarker | null;
onConfirm: (shape: UserMarker) => void;
}
export function ShapePlacementDialog({
open,
onOpenChange,
pendingShape,
onConfirm,
}: ShapePlacementDialogProps) {
const [name, setName] = useState("");
const [status, setStatus] = useState("");
const [notes, setNotes] = useState("");
const shapeType = pendingShape?.type ?? "";
const isLine = shapeType === "line";
const isPolygon = shapeType === "polygon";
const isCircle = shapeType === "circle";
const titleLabel = isLine ? "Jalan Baru" : isPolygon ? "Lahan Baru" : isCircle ? "Area Baru" : (LAND_MARKER_LABELS[shapeType] ?? "Item Baru");
// Auto-calculated metrics
const coordinates = pendingShape?.meta?.coordinates ?? [];
const radius = pendingShape?.meta?.radius ?? 0;
const length = isLine ? calcLineLength(coordinates) : 0;
const area = isPolygon
? calcPolygonArea(coordinates)
: isCircle
? Math.PI * radius * radius
: 0;
const statusOptions = isLine ? ROAD_STATUSES : isPolygon ? LAND_STATUSES : [];
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!pendingShape) return;
const finalName = name.trim() || pendingShape.name;
const updated: UserMarker = {
...pendingShape,
name: finalName,
meta: {
...pendingShape.meta,
poi_type: status || undefined,
notes: notes.trim() || undefined,
},
};
onConfirm(updated);
// Reset
setName("");
setStatus("");
setNotes("");
}
function handleCancel() {
setName("");
setStatus("");
setNotes("");
onOpenChange(false);
}
// Reset form when dialog opens with a new shape
useEffect(() => {
if (!pendingShape?.id) return;
setName("");
const t = pendingShape.type;
setStatus(t === "line" ? "Regency Road" : t === "polygon" ? "SHM" : "");
setNotes(pendingShape.meta?.notes ?? "");
}, [pendingShape?.id]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
<form onSubmit={handleSubmit} className="flex flex-col">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">{titleLabel}</DialogTitle>
<p className="text-center text-xs text-muted-foreground font-mono tabular-nums mt-1">
{pendingShape ? `${pendingShape.lat.toFixed(5)}, ${pendingShape.lng.toFixed(5)}` : ""}
</p>
</DialogHeader>
{/* Body */}
<div className="px-6 py-5 flex flex-col gap-5">
{/* Name */}
<Field label={isLine ? "Nama Jalan" : isPolygon ? "Nama Lahan" : isCircle ? "Nama Area" : "Nama Penanda"}>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={isLine ? "Contoh: Jl. Merdeka" : isPolygon ? "Contoh: Lahan Sawah A1" : isCircle ? "Contoh: Buffer Zone A" : "Contoh: Patok Batas Utara"}
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
{/* Notes (optional, all types) */}
<Field label="Catatan">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Catatan tambahan (opsional)"
rows={2}
className="w-full min-w-0 rounded-lg border border-input bg-muted/50 px-2.5 py-1.5 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 resize-none"
/>
</Field>
{/* Status selector (line / polygon only) */}
{statusOptions.length > 0 && (
<Field label="Status">
<StatusSelector options={statusOptions} value={status} onChange={setStatus} />
</Field>
)}
{/* Auto-calculated metrics */}
<div className={cn("grid gap-3", isCircle ? "grid-cols-2" : "grid-cols-1")}>
{isLine && (
<StatChip
label="Panjang"
value={length >= 1000
? `${(length / 1000).toFixed(2)} km`
: `${length.toFixed(1)} m`}
/>
)}
{(isPolygon || isCircle) && (
<StatChip
label="Luas"
value={area >= 10000
? `${(area / 10000).toFixed(2)} ha`
: `${area.toFixed(1)}`}
/>
)}
{isCircle && (
<StatChip label="Radius" value={`${radius.toFixed(1)} m`} />
)}
</div>
{/* Coordinate count */}
{(isLine || isPolygon) && coordinates.length > 0 && (
<p className="text-[10px] text-muted-foreground">
{coordinates.length} titik koordinat
</p>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 px-6 py-4 border-t">
<button
type="button"
onClick={handleCancel}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted"
>
Batal
</button>
<button
type="submit"
disabled={!name.trim()}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40"
>
Simpan &amp; Pasang
</button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
// Re-export helpers for use in edit dialog and leaflet-map
export { ROAD_STATUSES, LAND_STATUSES, calcLineLength, calcPolygonArea };
@@ -0,0 +1,111 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
export function LandingAbout() {
return (
<section id="about" className="bg-white pt-16 sm:pt-20 lg:pt-32 pb-12 sm:pb-16 lg:pb-24 overflow-hidden w-full">
<div className="w-full max-w-[1440px] mx-auto">
{/* Badge Row */}
<div className="px-5 sm:px-8 lg:px-12 flex items-center gap-3 mb-6 sm:mb-8">
<span className="w-6 h-6 sm:w-7 sm:h-7 rounded-full bg-gray-900 text-white text-[11px] sm:text-[12px] font-semibold flex items-center justify-center">
1
</span>
<span className="text-[12px] sm:text-[13px] font-medium text-gray-900 border border-gray-200 rounded-full px-3 sm:px-4 py-1 sm:py-1.5">
Introducing Waras GIS
</span>
</div>
{/* Heading */}
<div className="px-5 sm:px-8 lg:px-12">
<h2 className="font-medium leading-[1.12] tracking-[-0.02em] text-gray-900 mb-12 sm:mb-16 lg:mb-28 text-[clamp(1.5rem,4vw,3.2rem)] max-w-[900px]">
Kecerdasan spasial terpadu, menyajikan <br className="hidden sm:block" />
visualisasi data yang akurat & interaktif.
</h2>
</div>
{/* Content Area - Mobile & Tablet Stacked */}
<div className="lg:hidden px-5 sm:px-8 flex flex-col gap-8">
<div className="flex flex-col items-start gap-4">
<p className="text-[15px] sm:text-[17px] leading-[1.6] font-medium text-gray-900 max-w-[600px]">
Melalui integrasi data kemiskinan daerah, tata guna lahan, dan jaringan fasilitas pengisian energi, kami membantu mewujudkan tata ruang wilayah yang transparan dan berbasis data.
</p>
{/* About Button with Text Roll and Arrow - routes to Login */}
<Link href="/auth/login" className="group flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[13px] font-medium rounded-full pl-5 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer mt-2">
<span className="overflow-hidden h-[20px] relative inline-block">
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
<span className="h-[20px] flex items-center">Pelajari Selengkapnya</span>
<span className="h-[20px] flex items-center">Pelajari Selengkapnya</span>
</span>
</span>
<span className="w-7 h-7 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
<ArrowRight className="w-3.5 h-3.5" />
</span>
</Link>
</div>
{/* Images */}
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 w-full">
<img
src="https://images.higgs.ai/?default=1&output=webp&url=https%3A%2F%2Fd8j0ntlcm91z4.cloudfront.net%2Fuser_38xzZboKViGWJOttwIXH07lWA1P%2Fhf_20260516_090123_74be96d4-9c1b-40cf-932a-96f4f4babed3.png&w=1280&q=85"
alt="Analisis spasial tim perencanaan"
className="w-full sm:w-[45%] aspect-[438/346] rounded-xl sm:rounded-2xl object-cover"
/>
<img
src="https://images.higgs.ai/?default=1&output=webp&url=https%3A%2F%2Fd8j0ntlcm91z4.cloudfront.net%2Fuser_38xzZboKViGWJOttwIXH07lWA1P%2Fhf_20260516_090133_c157d30b-a99a-4477-bec1-a446149ec3f2.png&w=1280&q=85"
alt="Stasiun kerja visualisasi GIS"
className="w-full sm:w-[55%] aspect-[900/600] rounded-xl sm:rounded-2xl object-cover"
/>
</div>
</div>
{/* Content Area - Desktop Grid */}
<div className="hidden lg:grid grid-cols-[26%_1fr_48%] items-end gap-6 xl:gap-8 px-8 lg:px-12">
{/* Left Column Image */}
<div className="w-full">
<img
src="https://images.higgs.ai/?default=1&output=webp&url=https%3A%2F%2Fd8j0ntlcm91z4.cloudfront.net%2Fuser_38xzZboKViGWJOttwIXH07lWA1P%2Fhf_20260516_090123_74be96d4-9c1b-40cf-932a-96f4f4babed3.png&w=1280&q=85"
alt="Analisis spasial tim perencanaan"
className="w-full aspect-[438/346] rounded-2xl object-cover shadow-[0_4px_20px_rgba(0,0,0,0.02)]"
/>
</div>
{/* Center Column Copy & CTA */}
<div className="flex flex-col items-start justify-between self-start h-full pl-6 pr-4">
<p className="text-[16px] xl:text-[18px] leading-[1.65] font-medium text-gray-900 whitespace-nowrap">
Melalui integrasi data kemiskinan daerah,<br />
tata guna lahan, dan jaringan SPBU/EV,<br />
kami membantu pengambilan keputusan.
</p>
{/* About Button - routes to Login */}
<Link href="/auth/login" className="group flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[13px] font-medium rounded-full pl-5 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer mt-8">
<span className="overflow-hidden h-[20px] relative inline-block">
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
<span className="h-[20px] flex items-center">Pelajari Selengkapnya</span>
<span className="h-[20px] flex items-center">Pelajari Selengkapnya</span>
</span>
</span>
<span className="w-7 h-7 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
<ArrowRight className="w-3.5 h-3.5" />
</span>
</Link>
</div>
{/* Right Column Image */}
<div className="w-full">
<img
src="https://images.higgs.ai/?default=1&output=webp&url=https%3A%2F%2Fd8j0ntlcm91z4.cloudfront.net%2Fuser_38xzZboKViGWJOttwIXH07lWA1P%2Fhf_20260516_090133_c157d30b-a99a-4477-bec1-a446149ec3f2.png&w=1280&q=85"
alt="Stasiun kerja visualisasi GIS"
className="w-full aspect-[3/2] rounded-2xl object-cover shadow-[0_4px_24px_rgba(0,0,0,0.03)]"
/>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,96 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
export function LandingCta() {
return (
<section className="bg-white py-24 sm:py-32 relative overflow-hidden w-full border-t border-gray-100">
<div className="w-full max-w-[1440px] mx-auto px-5 sm:px-8 lg:px-12 relative flex flex-col items-center justify-center text-center">
{/* Left Side ASCII Art - Enlarged Variant */}
<div className="absolute left-2 sm:left-6 lg:left-10 top-1/2 -translate-y-1/2 hidden md:block pointer-events-none z-10 select-none opacity-25">
<pre className="text-[10px] sm:text-[13px] md:text-[14px] leading-[1.15] font-mono text-gray-400 text-left">
{` .
...
..\\\\/ .
..AAA.AA
......AA .
..A .AAAAA .
..A .AAAA * . ..A . ..hA\\/h .
. . . .. * . .. V . Vh .
. . . . . . .. h A - V .
A . . ..A A . .V . V4 .
4V44A.*.VVV A444V4---AA
..A4\\\\----.V . .AAAA444444A4V444A...
A.--444444444V4444A..* .AVA
..V--AAA. V4....... ..A .
... A44V4 .. ...
.. . VVV .
.
`}
</pre>
</div>
{/* Right Side ASCII Art - Enlarged Variant */}
<div className="absolute right-2 sm:right-6 lg:right-10 top-1/2 -translate-y-1/2 hidden md:block pointer-events-none z-10 select-none opacity-25">
<pre className="text-[10px] sm:text-[13px] md:text-[14px] leading-[1.15] font-mono text-gray-400 text-left">
{` A
-.
..A .
A444A .
..A . 4 .
.... AA . .
..A .AAAA . ..A . . . .
.....AAAA . . ..AA . .A .AVA .
. . A . ..A . .. .V4 . .4V44A .
. A . ..A4V . A44444444V4 .
4V4V4.--AA
..AAAA444444A4V444A...
A.AVAAAAA4A4A....
A4 ..V.AVA
..V4V4A...
..A...
..A .
.. .
.
`}
</pre>
</div>
{/* Center Heading */}
<h2 className="font-medium leading-[1.12] tracking-[-0.03em] text-gray-900 text-[clamp(1.75rem,5vw,3.5rem)] max-w-[850px] mb-10 sm:mb-12 relative z-20">
Kelola data pemetaan daerah Anda <br className="hidden sm:block" /> tanpa hambatan.
</h2>
{/* CTA Buttons */}
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-5 relative z-20">
{/* Orange Button */}
<Link href="/auth/login" className="group flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[13px] sm:text-[14px] font-medium rounded-full pl-5 sm:pl-6 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer">
<span className="overflow-hidden h-[20px] relative inline-block">
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
<span className="h-[20px] flex items-center">Buka Dashboard</span>
<span className="h-[20px] flex items-center">Buka Dashboard</span>
</span>
</span>
<span className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
<ArrowRight className="w-4 h-4" />
</span>
</Link>
{/* Outline Button */}
<Link href="/auth/login" className="group flex items-center gap-3 border border-gray-900 text-gray-900 hover:bg-gray-900 hover:text-white text-[13px] sm:text-[14px] font-medium rounded-full pl-5 sm:pl-6 pr-2 py-2 transition-all duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer">
<span className="overflow-hidden h-[20px] relative inline-block">
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
<span className="h-[20px] flex items-center">Pelajari Dokumentasi</span>
<span className="h-[20px] flex items-center text-white">Pelajari Dokumentasi</span>
</span>
</span>
<span className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-gray-900 text-white flex items-center justify-center transition-all duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:bg-white group-hover:text-gray-900 group-hover:rotate-[-45deg] shrink-0">
<ArrowRight className="w-4 h-4" />
</span>
</Link>
</div>
</div>
</section>
);
}
@@ -0,0 +1,96 @@
import Link from "next/link";
export function LandingFooter() {
return (
<footer className="bg-[#0b0a09] text-gray-400 relative overflow-hidden pt-20 sm:pt-28">
{/*
Gradient Layer matching image_7203fe.jpg:
A smooth radial ambient glow originating from the center-bottom to softly illuminate
the giant typography, fading into a richer onyx black near the top.
*/}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center_bottom,_rgba(40,38,35,0.45)_0%,_rgba(11,10,9,0)_70%)] pointer-events-none z-0"></div>
{/*
High-fidelity Film Grain Texture Overlay:
Using a dense, fine-grained SVG noise filter pattern to replicate the textured,
premium look from the reference image.
*/}
<div className="absolute inset-0 pointer-events-none opacity-[0.18] mix-blend-overlay z-10 select-none">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
<filter id="grainNoise">
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" stitchTiles="stitch" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.15 0" />
</filter>
<rect width="100%" height="100%" filter="url(#grainNoise)" />
</svg>
</div>
<div className="w-full max-w-[1440px] mx-auto px-5 sm:px-8 lg:px-12 relative z-20">
{/* Top Info and Links columns */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12 pb-16 sm:pb-20">
{/* Logo and Pitch */}
<div className="col-span-2 flex flex-col items-start gap-4 pr-0 sm:pr-8">
<div className="w-10 h-10 bg-white text-gray-900 rounded-full flex items-center justify-center font-bold text-[11px] tracking-tight">
W
</div>
<p className="text-[14px] leading-relaxed text-gray-400 max-w-[320px] mt-2">
Sistem Informasi Geografis terpadu untuk pemetaan statistik kemiskinan, tata guna lahan, dan jaringan fasilitas infrastruktur energi.
</p>
</div>
{/* Column 1: Modul Peta */}
<div className="flex flex-col items-start">
<span className="text-[11px] tracking-wider text-gray-500 font-bold uppercase mb-4 sm:mb-5">
Modul Peta
</span>
<div className="flex flex-col gap-2.5 sm:gap-3 text-[14px]">
<a href="#projects" className="hover:text-white transition-colors duration-300">Peta Kemiskinan</a>
<a href="#projects" className="hover:text-white transition-colors duration-300">Lahan & Jalan</a>
<a href="#projects" className="hover:text-white transition-colors duration-300">SPBU & EV Charger</a>
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Analisis Wilayah</Link>
</div>
</div>
{/* Column 2: Dokumen & Kontak */}
<div className="flex flex-col gap-8">
{/* Dokumen */}
<div className="flex flex-col items-start">
<span className="text-[11px] tracking-wider text-gray-500 font-bold uppercase mb-4">
Dokumen
</span>
<div className="flex flex-col gap-2.5 text-[14px]">
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Pusat Bantuan</Link>
<Link href="/auth/login" className="hover:text-white transition-colors duration-300 font-medium text-gray-300">Play by the Rules</Link>
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Privacy Policy</Link>
</div>
</div>
{/* Kontak */}
<div className="flex flex-col items-start">
<span className="text-[11px] tracking-wider text-gray-500 font-bold uppercase mb-4">
Kontak
</span>
<div className="flex flex-col gap-2.5 text-[14px]">
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Hubungi Kami</Link>
<Link href="/auth/login" className="hover:text-white transition-colors duration-300">Keamanan Data</Link>
</div>
</div>
</div>
</div>
{/* Giant typography section matching the second image layout */}
<div className="w-full text-center select-none pointer-events-none overflow-hidden relative h-[10vw] sm:h-[12vw] flex items-end justify-center mt-8 sm:mt-16">
<span className="text-[28vw] font-bold text-white leading-[0.7] tracking-normal block opacity-95 translate-y-[20%] select-none">
Waras
</span>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,128 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { ArrowRight, Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useScroll } from "@/hooks/use-scroll";
import { Portal, PortalBackdrop } from "@/components/portal";
const navLinks = [
{ label: "Tentang", href: "#about" },
{ label: "Modul Peta", href: "#projects" },
{ label: "Dokumentasi", href: "/auth/login" },
];
export function LandingHeader() {
const scrolled = useScroll(10);
const [open, setOpen] = useState(false);
return (
<header className="fixed inset-x-0 top-0 z-50">
<div className="w-full max-w-[1440px] mx-auto p-2 sm:p-3">
<nav
className={cn(
"flex items-center justify-between rounded-full p-[5px] border border-transparent transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)]",
scrolled
? "bg-white shadow-[0_2px_12px_rgba(0,0,0,0.06)] border-gray-100/50"
: "bg-white/70 backdrop-blur-sm"
)}
>
{/* Brand */}
<Link
href="/"
className="w-9 h-9 sm:w-10 sm:h-10 bg-gray-900 rounded-full flex items-center justify-center text-white font-bold tracking-tight text-[10px] sm:text-[11px] hover:bg-gray-800 transition-colors duration-300 shrink-0"
>
W
</Link>
{/* Desktop nav links */}
<div className="hidden md:flex items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.label}
href={link.href}
className="text-[14px] font-medium text-gray-900 hover:text-gray-500 transition-colors duration-300"
>
{link.label}
</Link>
))}
</div>
{/* Desktop CTA - routes to Login */}
<Link
href="/auth/login"
className="group hidden md:flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[14px] font-medium rounded-full pl-6 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer"
>
<span className="overflow-hidden h-[20px] relative inline-block">
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
<span className="h-[20px] flex items-center">Login</span>
<span className="h-[20px] flex items-center">Login</span>
</span>
</span>
<span className="w-8 h-8 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
<ArrowRight className="w-4 h-4" />
</span>
</Link>
{/* Mobile menu trigger */}
<button
onClick={() => setOpen((value) => !value)}
className="md:hidden w-9 h-9 sm:w-10 sm:h-10 bg-gray-900 rounded-full flex items-center justify-center text-white transition-transform duration-300 active:scale-95 cursor-pointer shrink-0"
aria-label="Toggle Navigation Menu"
aria-controls="landing-mobile-nav"
aria-expanded={open}
>
{open ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
</button>
</nav>
</div>
{/* Mobile Menu Overlay */}
{open && (
<Portal className="md:hidden justify-end" id="landing-mobile-nav">
<PortalBackdrop onClick={() => setOpen(false)} />
<div className="bg-white rounded-2xl mx-3 mb-3 p-6 flex flex-col gap-6 shadow-2xl animate-in slide-in-from-bottom duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]">
<div className="flex justify-between items-center border-b pb-4 border-gray-100">
<span className="text-[12px] font-semibold tracking-wider uppercase text-gray-900">
Waras Portal GIS
</span>
<button
onClick={() => setOpen(false)}
className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center text-gray-700 hover:bg-gray-200 transition-colors"
aria-label="Tutup Menu"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-col gap-4 py-2">
{navLinks.map((link) => (
<Link
key={link.label}
href={link.href}
onClick={() => setOpen(false)}
className="text-[28px] sm:text-[32px] font-medium text-gray-900 hover:text-primary transition-colors"
>
{link.label}
</Link>
))}
</div>
{/* Action Button - routes to Login */}
<Link
href="/auth/login"
onClick={() => setOpen(false)}
className="group w-full flex items-center justify-between bg-primary text-primary-foreground text-[14px] font-medium rounded-full pl-6 pr-2 py-2.5 transition-all duration-300 hover:bg-primary/80"
>
<span>Login</span>
<span className="w-8 h-8 rounded-full bg-white flex items-center justify-center text-primary">
<ArrowRight className="w-4 h-4" />
</span>
</Link>
</div>
</Portal>
)}
</header>
);
}
@@ -0,0 +1,80 @@
"use client";
import Link from "next/link";
import dynamic from "next/dynamic";
import { ArrowRight } from "lucide-react";
// Dynamically import shaders to prevent SSR errors (WebGL/Canvas requires browser APIs)
const Shader = dynamic(() => import("shaders/react").then((mod) => mod.Shader), { ssr: false });
const Swirl = dynamic(() => import("shaders/react").then((mod) => mod.Swirl), { ssr: false });
const ChromaFlow = dynamic(() => import("shaders/react").then((mod) => mod.ChromaFlow), { ssr: false });
const FlutedGlass = dynamic(() => import("shaders/react").then((mod) => mod.FlutedGlass), { ssr: false });
const FilmGrain = dynamic(() => import("shaders/react").then((mod) => mod.FilmGrain), { ssr: false });
export function LandingHero() {
return (
<section className="relative flex min-h-screen w-full flex-col justify-center overflow-hidden bg-[#EFEFEF]">
{/* Animated WebGL Shader Overlay */}
<div className="absolute inset-0 z-10 pointer-events-none w-full h-full">
<Shader className="w-full h-full">
<Swirl colorA="#ffffff" colorB="#f0f0f0" detail={1.7} />
<ChromaFlow
baseColor="#ffffff"
downColor="#ff5f03"
leftColor="#ff5f03"
rightColor="#ff5f03"
upColor="#ff5f03"
momentum={13}
radius={3.5}
/>
<FlutedGlass
aberration={0.61}
angle={31}
frequency={8}
highlight={0.12}
highlightSoftness={0}
lightAngle={-90}
refraction={4}
shape="rounded"
softness={1}
speed={0.15}
/>
<FilmGrain strength={0.05} />
</Shader>
</div>
{/* Hero Content */}
<div className="relative z-20 w-full max-w-[1440px] mx-auto px-5 sm:px-8 lg:px-12 pb-14 sm:pb-16 lg:pb-20">
<div className="text-[13px] sm:text-[14px] text-gray-900 font-semibold tracking-wider uppercase mb-5 sm:mb-8">
Waras Portal GIS
</div>
<h1 className="font-medium leading-[1.08] tracking-[-0.03em] text-gray-900 text-[clamp(1.75rem,7vw,4.2rem)] sm:text-[clamp(2.5rem,5vw,4.2rem)] max-w-[1100px]">
Satu peta untuk keputusan daerah yang lebih baik.
</h1>
{/* CTA Row */}
<div className="mt-8 sm:mt-12 flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-5">
{/* Orange Button with Text Roll and Rotated Arrow - routes to Login */}
<Link href="/auth/login" className="group flex items-center gap-3 bg-primary hover:bg-primary/80 text-primary-foreground text-[13px] sm:text-[14px] font-medium rounded-full pl-5 sm:pl-6 pr-2 py-2 transition-colors duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] cursor-pointer">
<span className="overflow-hidden h-[20px] relative inline-block">
<span className="flex flex-col transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:-translate-y-1/2">
<span className="h-[20px] flex items-center">Buka Dashboard</span>
<span className="h-[20px] flex items-center">Buka Dashboard</span>
</span>
</span>
<span className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-white flex items-center justify-center text-primary transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] group-hover:rotate-[-45deg] shrink-0">
<ArrowRight className="w-4 h-4" />
</span>
</Link>
</div>
</div>
</section>
);
}
@@ -0,0 +1,97 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { LinkIcon } from "@/components/landing/link-icon";
export function LandingProjects() {
return (
<section id="projects" className="bg-[#F5F5F5] pt-16 sm:pt-20 lg:pt-28 pb-16 sm:pb-20 lg:pb-28 w-full">
<div className="w-full max-w-[1440px] mx-auto">
{/* Badge Row */}
<div className="px-5 sm:px-8 lg:px-12 flex items-center gap-3 mb-6 sm:mb-8">
<span className="w-6 h-6 sm:w-7 sm:h-7 rounded-full bg-gray-900 text-white text-[11px] sm:text-[12px] font-semibold flex items-center justify-center">
2
</span>
<span className="text-[12px] sm:text-[13px] font-medium text-gray-900 border border-gray-300 rounded-full px-3 sm:px-4 py-1 sm:py-1.5">
Modul Peta Visual
</span>
</div>
{/* Heading */}
<div className="px-5 sm:px-8 lg:px-12 mb-10 sm:mb-14 lg:mb-16">
<h2 className="font-medium leading-[1.08] tracking-[-0.03em] text-gray-900 text-[clamp(1.75rem,7vw,4.2rem)] sm:text-[clamp(2.5rem,5vw,4.2rem)]">
Modul Sistem
</h2>
</div>
{/* Grid Layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 sm:gap-6 lg:gap-7 px-5 sm:px-8 lg:px-12">
{/* Card 1: Poverty Map - links to Login */}
<Link href="/auth/login" className="flex flex-col group cursor-pointer text-left">
{/* Media Container */}
<div className="relative aspect-[329/246] rounded-2xl overflow-hidden bg-[#1a1d2e] shadow-[0_4px_16px_rgba(0,0,0,0.015)]">
<video
src="https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260516_122702_390f5305-8719-41d5-ae80-d23ab3796c28.mp4"
autoPlay
muted
loop
playsInline
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-102"
/>
{/* Hover expandable white circle button */}
<div className="absolute bottom-4 left-4 h-9 bg-white text-gray-900 rounded-full flex items-center overflow-hidden transition-all duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] w-9 group-hover:w-[148px] px-2.5 justify-between shadow-md">
<span className="text-[13px] font-medium whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100 pl-1 text-gray-900">
Buka Peta
</span>
<LinkIcon className="w-3.5 h-3.5 shrink-0 text-gray-900 transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] -rotate-45 group-hover:rotate-0" />
</div>
</div>
{/* Descriptions */}
<p className="text-[13px] sm:text-[14px] text-gray-600 mt-4 leading-relaxed font-normal">
Sistem analisis kemiskinan terpadu dengan visualisasi statistik desil wilayah dan indeks prioritas bantuan.
</p>
<h3 className="text-[14px] sm:text-[15px] font-semibold text-gray-900 mt-1">
Peta & Statistik Kemiskinan
</h3>
</Link>
{/* Card 2: Gas Station Map - links to Login */}
<Link href="/auth/login" className="flex flex-col group cursor-pointer text-left">
{/* Media Container */}
<div className="relative aspect-square rounded-2xl overflow-hidden bg-[#6b6b6b] shadow-[0_4px_16px_rgba(0,0,0,0.015)]">
<video
src="https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260516_123323_f909c2b8-ff6c-4edf-882b-8ebcdbe389b5.mp4"
autoPlay
muted
loop
playsInline
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-102"
/>
{/* Hover expandable dark circle button */}
<div className="absolute bottom-4 left-4 h-9 bg-gray-900 text-white rounded-full flex items-center overflow-hidden transition-all duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] w-9 group-hover:w-[168px] px-2.5 justify-between shadow-md">
<span className="text-[13px] font-medium whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100 pl-1 text-white">
Buka Peta
</span>
<ArrowRight className="w-3.5 h-3.5 shrink-0 text-white transition-transform duration-500 ease-[cubic-bezier(0.25,0.1,0.25,1)] -rotate-45 group-hover:rotate-0" />
</div>
</div>
{/* Descriptions */}
<p className="text-[13px] sm:text-[14px] text-gray-600 mt-4 leading-relaxed font-normal">
Pemetaan stasiun pengisian energi (SPBU & EV Charger) beserta rincian fasilitas bengkel dan status operasional 24 jam.
</p>
<h3 className="text-[14px] sm:text-[15px] font-semibold text-gray-900 mt-1">
Peta SPBU, EV Charger & Bengkel
</h3>
</Link>
</div>
</div>
</section>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { SVGProps } from "react";
export function LinkIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { XIcon } from "@/components/ui/phosphor-icons";
const latestChange = {
badge: "CHANGELOG",
title: "Product update",
description: "Performance boosts and UI polish.", // TIP: Use a single line of text for the description. (max 5 words)
readMore: { href: "#", label: "Learn more" },
} as const;
export function LatestChange() {
const [isOpen, setIsOpen] = useState(true);
if (!isOpen) {
return null;
}
return (
<div
className={cn(
"group/latest-change size-full min-h-27 justify-center border-t",
"relative flex size-full flex-col gap-1 overflow-hidden px-4 pt-3 pb-1 *:text-nowrap",
"transition-opacity group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:opacity-0"
)}
>
<span className="font-light font-mono text-[10px] text-muted-foreground">
{latestChange.badge}
</span>
<p className="font-medium text-xs">{latestChange.title}</p>
<span className="text-[10px] text-muted-foreground">
{latestChange.description}
</span>
<Button className="w-max px-0 font-light text-xs" size="sm" variant="link" render={<a href={latestChange.readMore.href} />} nativeButton={false}>{latestChange.readMore.label}</Button>
<Button
className="absolute top-2 right-2 z-10 size-6 rounded-full opacity-0 transition-opacity group-hover/latest-change:opacity-100"
onClick={() => setIsOpen(false)}
size="icon-sm"
variant="ghost"
>
<XIcon className="size-3.5 text-muted-foreground" />{" "}
</Button>
</div>
);
}
+810
View File
@@ -0,0 +1,810 @@
"use client";
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { BRAND_COLORS } from "@/lib/map-data";
export interface UserMarker {
id: string;
lat: number;
lng: number;
type: string;
name: string;
meta?: {
poverty_level?: string;
family_count?: number;
penghasilan?: number;
fuzzy_label?: string;
fuzzy_score?: number;
fuzzy_detail?: Record<string, { kategori: string; derajat: number }>;
notes?: string;
poi_type?: string;
radius?: number;
coordinates?: [number, number][];
gas_types?: string[];
created_by_username?: string;
created_at?: string;
updated_at?: string;
};
}
interface LeafletMapProps {
mode: "poverty" | "land" | "gas-station";
selectedYear?: number;
selectedId?: string;
onSelect?: (id: string) => void;
visibilityFilters?: Record<string, boolean>;
fuelPriceDisplay?: "ron92" | "ron95" | "diesel";
fullBleed?: boolean;
mapStyle?: "street" | "satellite";
onMapClick?: (lat: number, lng: number) => void;
userMarkers?: UserMarker[];
onMarkerClick?: (marker: UserMarker) => void;
onMapReady?: (mapInstance: any) => void;
onShapeCreated?: (shape: UserMarker) => void;
placingMarkerType?: string | null;
dragDropEnabled?: boolean;
onMarkerDragEnd?: (marker: UserMarker, newLat: number, newLng: number) => void;
}
const POVERTY_LEVEL_COLOR: Record<string, string> = {
Extreme: "#ef4444",
Miskin: "#f59e0b",
Rentan: "#3b82f6",
};
const FUZZY_LABEL_COLOR: Record<string, string> = {
"SANGAT TINGGI": "#ef4444",
"TINGGI": "#f97316",
"SEDANG": "#f59e0b",
"RENDAH": "#10b981",
"TIDAK PRIORITAS":"#6b7280",
};
function fmt(n: number) {
return "Rp " + n.toLocaleString("id-ID");
}
const POI_TYPE_LABELS: Record<string, string> = {
marker: "Data Warga",
mosque: "Masjid",
church: "Gereja",
cathedral: "Gereja Katolik",
temple: "Pura",
vihara: "Vihara",
klenteng: "Klenteng",
synagogue: "Sinagog",
clinic: "Klinik",
"food-bank": "Lumbung Pangan",
school: "Sekolah",
flag: "Tengara",
protected: "Kawasan Lindung",
registry: "Kantor Pendaftaran",
"charging-station": "Pengisi Daya EV",
"gas-pump": "SPBU",
wrench: "Bengkel",
line: "Garis",
polygon: "Poligon",
circle: "Lingkaran",
};
function buildMarkerTooltip(m: UserMarker): string {
const meta = m.meta;
const isHousehold = m.type === "marker";
const typeLabel = POI_TYPE_LABELS[m.type] ?? m.type.replace(/-/g, " ");
const base = `
<div style="font-family:system-ui,sans-serif;min-width:160px;max-width:220px;">
<div style="font-size:13px;font-weight:700;margin-bottom:4px;border-bottom:1px solid rgba(0,0,0,0.1);padding-bottom:4px;">
${m.name}
</div>`;
if (isHousehold && meta) {
const lvlColor = POVERTY_LEVEL_COLOR[meta.poverty_level ?? ""] ?? "#6b7280";
const fuzzyColor = FUZZY_LABEL_COLOR[meta.fuzzy_label ?? ""] ?? "#6b7280";
const displayPovertyLevel = meta.poverty_level === "Extreme" ? "Ekstrem" : (meta.poverty_level ?? "—");
return base + `
<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 8px;font-size:11px;color:#374151;">
<span style="color:#9ca3af;">Tingkat</span>
<span style="font-weight:600;color:${lvlColor};">${displayPovertyLevel}</span>
<span style="color:#9ca3af;">Tanggungan</span>
<span>${meta.family_count ?? "—"} jiwa</span>
<span style="color:#9ca3af;">Penghasilan</span>
<span>${meta.penghasilan != null ? fmt(meta.penghasilan) : "—"}/bln</span>
${meta.fuzzy_label ? `
<span style="color:#9ca3af;">Prioritas</span>
<span style="font-weight:600;color:${fuzzyColor};">${meta.fuzzy_label}</span>
` : ""}
${meta.fuzzy_score != null ? `
<span style="color:#9ca3af;">Skor</span>
<span>${meta.fuzzy_score.toFixed(2)}</span>
` : ""}
${meta.notes ? `
<span style="color:#9ca3af;">Catatan</span>
<span style="font-style:italic;">${meta.notes}</span>
` : ""}
</div>
<div style="margin-top:4px;font-size:10px;color:#9ca3af;">
${m.lat.toFixed(5)}, ${m.lng.toFixed(5)}
</div>
</div>`;
}
const gasTypesHtml = m.type === "gas-pump" && meta?.gas_types && meta.gas_types.length > 0
? `<div style="margin-top:4px;margin-bottom:4px;display:flex;flex-wrap:wrap;gap:2px;">
${meta.gas_types.map((gt: string) => `<span style="background-color:rgba(255,59,31,0.1);color:#ff3b1f;border:1px solid rgba(255,59,31,0.2);padding:1px 4px;border-radius:3px;font-size:9px;font-weight:600;white-space:nowrap;">${gt}</span>`).join("")}
</div>`
: "";
return base + `
<div style="font-size:11px;color:#6b7280;text-transform:capitalize;margin-bottom:4px;">${typeLabel}</div>
${meta?.poi_type ? `<div style="font-size:11px;font-weight:600;color:#374151;margin-bottom:2px;">${meta.poi_type}</div>` : ""}
${meta?.notes ? `<div style="font-size:11px;color:#374151;font-style:italic;margin-bottom:2px;">${meta.notes}</div>` : ""}
${gasTypesHtml}
<div style="margin-top:4px;font-size:10px;color:#9ca3af;">
${m.lat.toFixed(5)}, ${m.lng.toFixed(5)}
</div>
</div>`;
}
export default function LeafletMap({
mode,
selectedYear = 2026,
selectedId,
onSelect,
visibilityFilters,
fuelPriceDisplay = "ron95",
fullBleed = true,
mapStyle = "street",
onMapClick,
userMarkers,
onMarkerClick,
onMapReady,
onShapeCreated,
placingMarkerType,
dragDropEnabled = false,
onMarkerDragEnd,
}: LeafletMapProps) {
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<any>(null);
const [leafletLoaded, setLeafletLoaded] = useState(false);
const layersRef = useRef<any[]>([]);
const streetLayerRef = useRef<any>(null);
const satelliteLayerRef = useRef<any>(null);
// 1. Inject Leaflet JS/CSS and optionally Geoman for land drawing
useEffect(() => {
let isMounted = true;
// Promise-based script loader that handles already-loaded scripts
const loadScript = (id: string, src: string): Promise<void> => {
return new Promise((resolve, reject) => {
const existing = document.getElementById(id) as HTMLScriptElement | null;
if (existing) {
// Script tag exists — check if it already finished loading
// by testing for the global it should have created
if (id === "leaflet-js" && (window as any).L) { resolve(); return; }
if (id === "geoman-js" && (window as any).L?.PM) { resolve(); return; }
// Still loading — listen for completion
const onLoad = () => { existing.removeEventListener("load", onLoad); resolve(); };
const onError = () => { existing.removeEventListener("error", onError); reject(new Error(`Script ${id} failed`)); };
existing.addEventListener("load", onLoad);
existing.addEventListener("error", onError);
return;
}
const script = document.createElement("script");
script.id = id;
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load ${src}`));
document.body.appendChild(script);
});
};
const loadAll = async () => {
if (typeof window === "undefined") return;
// Inject Leaflet CSS
if (!document.getElementById("leaflet-css")) {
const link = document.createElement("link");
link.id = "leaflet-css";
link.rel = "stylesheet";
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
document.head.appendChild(link);
}
// Inject Geoman CSS for land mode
if (mode === "land" && !document.getElementById("geoman-css")) {
const link = document.createElement("link");
link.id = "geoman-css";
link.rel = "stylesheet";
link.href = "https://unpkg.com/@geoman-io/leaflet-geoman-free@2.17.0/dist/leaflet-geoman.css";
document.head.appendChild(link);
}
try {
// Step 1: Load Leaflet core (skip if already loaded)
if (!(window as any).L) {
await loadScript("leaflet-js", "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js");
}
// Step 2: For land mode, load Geoman AFTER Leaflet (skip if already loaded)
if (mode === "land" && !(window as any).L?.PM) {
await loadScript("geoman-js", "https://unpkg.com/@geoman-io/leaflet-geoman-free@2.17.0/dist/leaflet-geoman.js");
}
// Both scripts loaded — safe to create the map
if (isMounted) setLeafletLoaded(true);
} catch (err) {
console.error("[LeafletMap] Script loading failed:", err);
// Still allow non-land modes to proceed without Geoman
if (mode !== "land" && (window as any).L && isMounted) {
setLeafletLoaded(true);
}
}
};
loadAll();
return () => {
isMounted = false;
};
}, [mode]);
// 2. Initialize Leaflet Map
useEffect(() => {
if (!leafletLoaded || !mapContainerRef.current) return;
const L = (window as any).L;
if (!L) return;
// For land mode, verify Geoman is truly available before creating the map
if (mode === "land" && !L.PM) {
console.warn("[LeafletMap] Geoman not available for land mode — aborting map init");
return;
}
// Create Map Instance centered in Jakarta
const map = L.map(mapContainerRef.current, {
center: [-6.20, 106.82],
zoom: 12,
zoomControl: true
});
// Safety net: if Geoman loaded but map.pm is still undefined,
// manually re-initialize Geoman on this map instance
if (mode === "land" && L.PM && !map.pm) {
try {
L.PM.reInitLayer(map);
} catch (e) {
console.warn("[LeafletMap] L.PM.reInitLayer failed, trying manual init:", e);
// Fallback: Geoman v2 attaches via addInitHook, try calling it directly
if (typeof map._initPathRoot === "function") map._initPathRoot();
}
}
// Hide Geoman's default toolbar — we drive drawing programmatically
if (mode === "land" && map.pm) {
try {
map.pm.addControls({ position: "topleft" });
map.pm.removeControls();
} catch (_) { /* ignore */ }
}
const street = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; OpenStreetMap contributors'
});
const satellite = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", {
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
});
streetLayerRef.current = street;
satelliteLayerRef.current = satellite;
// Only the active layer lives on the map — a hidden (opacity 0) tile
// layer still downloads tiles on every pan/zoom.
const active = mapStyle === "satellite" ? satellite : street;
active.setOpacity(1);
active.addTo(map);
mapInstanceRef.current = map;
onMapReady?.(map);
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
mapInstanceRef.current = null;
}
};
}, [leafletLoaded]);
// 2.5 Handle Map Style Cross-Fade Animation
useEffect(() => {
const map = mapInstanceRef.current;
const street = streetLayerRef.current;
const satellite = satelliteLayerRef.current;
if (!map || !street || !satellite) return;
const incoming = mapStyle === "satellite" ? satellite : street;
const outgoing = mapStyle === "satellite" ? street : satellite;
if (!map.hasLayer(incoming)) {
incoming.setOpacity(0);
incoming.addTo(map);
}
if (!map.hasLayer(outgoing)) {
// Initial mount — active layer already on the map, nothing to fade.
incoming.setOpacity(1);
return;
}
let rafId = 0;
let start: number | null = null;
const duration = 400; // 400ms transition
const animate = (timestamp: number) => {
if (!start) start = timestamp;
const progress = Math.min((timestamp - start) / duration, 1);
incoming.setOpacity(progress);
outgoing.setOpacity(1 - progress);
if (progress < 1) {
rafId = requestAnimationFrame(animate);
} else {
// Detach the hidden layer so it stops requesting tiles.
map.removeLayer(outgoing);
}
};
rafId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafId);
}, [mapStyle, leafletLoaded]);
// 3. Render Layers based on Props
useEffect(() => {
const map = mapInstanceRef.current;
const L = (window as any).L;
if (!map || !L) return;
// Clear existing layers
layersRef.current.forEach((layer) => map.removeLayer(layer));
layersRef.current = [];
if (mode === "poverty") {
// Poverty mode renders only real data: household + POI markers from
// the database (drawn below from userMarkers). No mock choropleth.
} else if (mode === "land") {
// Land mode renders only real data: land_markers + land_shapes from
// the database (drawn below from userMarkers). No mock zone polygons.
} else if (mode === "gas-station") {
// Gas station mode renders only real data from the database (drawn below from userMarkers)
}
// Status-based colors for shapes
const ROAD_STATUS_COLORS: Record<string, string> = {
"National Road": "#ef4444",
"Provincial Road": "#eab308",
"Regency Road": "#22c55e",
};
const LAND_STATUS_COLORS: Record<string, string> = {
"SHM": "#a855f7",
"HGB": "#f97316",
"HGU": "#14b8a6",
"HP": "#ec4899",
};
// Draw User-Placed Markers and Shapes
const RELIGION_TYPES = new Set(["mosque","church","cathedral","temple","vihara","klenteng","synagogue"]);
if (userMarkers && userMarkers.length > 0) {
userMarkers.forEach((m) => {
// Visibility filter (land mode): skip rendering if this type is hidden.
// visibilityFilters is undefined for poverty/gas-station, so this is a no-op there.
if (visibilityFilters && visibilityFilters[m.type] === false) return;
// Render user-placed polylines (lines)
if (m.type === "line" && m.meta?.coordinates) {
const validCoords = m.meta.coordinates.filter(
(c: any) => Array.isArray(c) && typeof c[0] === "number" && !isNaN(c[0]) && typeof c[1] === "number" && !isNaN(c[1])
);
if (validCoords.length >= 2) {
const lineColor = ROAD_STATUS_COLORS[m.meta?.poi_type ?? ""] ?? "#ff3b1f";
const polyline = L.polyline(validCoords, {
color: lineColor,
weight: 4,
opacity: 0.85
}).addTo(map);
const statusLabel = m.meta?.poi_type ? ` · ${m.meta.poi_type}` : "";
polyline.bindTooltip(`<strong>${m.name}</strong><br/><span style="color:${lineColor}">GARIS${statusLabel}</span>`, { sticky: true });
polyline.on("click", (e: any) => {
e.originalEvent?.stopPropagation();
onMarkerClick?.(m);
});
layersRef.current.push(polyline);
}
return;
}
// Render user-placed polygons
if (m.type === "polygon" && m.meta?.coordinates) {
const validCoords = m.meta.coordinates.filter(
(c: any) => Array.isArray(c) && typeof c[0] === "number" && !isNaN(c[0]) && typeof c[1] === "number" && !isNaN(c[1])
);
if (validCoords.length >= 3) {
const polyColor = LAND_STATUS_COLORS[m.meta?.poi_type ?? ""] ?? "#ff3b1f";
const polygon = L.polygon(validCoords, {
fillColor: polyColor,
fillOpacity: 0.25,
color: polyColor,
weight: 2,
opacity: 0.85
}).addTo(map);
const statusLabel = m.meta?.poi_type ? ` · ${m.meta.poi_type}` : "";
polygon.bindTooltip(`<strong>${m.name}</strong><br/><span style="color:${polyColor}">POLIGON${statusLabel}</span>`, { sticky: true });
polygon.on("click", (e: any) => {
e.originalEvent?.stopPropagation();
onMarkerClick?.(m);
});
layersRef.current.push(polygon);
}
return;
}
// Render user-placed circles
if (m.type === "circle" && typeof m.lat === "number" && !isNaN(m.lat) && typeof m.lng === "number" && !isNaN(m.lng) && (m.meta?.radius ?? 0) > 0) {
const circle = L.circle([m.lat, m.lng], {
radius: m.meta!.radius!,
fillColor: "#ff3b1f",
fillOpacity: 0.2,
color: "#ff3b1f",
weight: 2,
opacity: 0.85
}).addTo(map);
circle.bindTooltip(`<strong>${m.name}</strong><br/>Jenis: LINGKARAN<br/>Radius: ${m.meta!.radius!.toFixed(1)}m`, { sticky: true });
circle.on("click", (e: any) => {
e.originalEvent?.stopPropagation();
onMarkerClick?.(m);
});
layersRef.current.push(circle);
return;
}
let markerColor = "rgb(59, 130, 246)";
let iconName = "map-pin";
switch (m.type) {
case "marker":
markerColor = FUZZY_LABEL_COLOR[m.meta?.fuzzy_label ?? ""] ?? markerColor;
iconName = "map-pin";
break;
case "mosque":
markerColor = "rgb(16, 185, 129)";
iconName = "mosque";
break;
case "church":
markerColor = "rgb(139, 92, 246)";
iconName = "church";
break;
case "cathedral":
markerColor = "rgb(124, 58, 237)";
iconName = "crown-cross";
break;
case "temple":
markerColor = "rgb(234, 88, 12)";
iconName = "hands-praying";
break;
case "vihara":
markerColor = "rgb(202, 138, 4)";
iconName = "hands-praying";
break;
case "klenteng":
markerColor = "rgb(185, 28, 28)";
iconName = "hands-praying";
break;
case "synagogue":
markerColor = "rgb(245, 158, 11)";
iconName = "synagogue";
break;
case "clinic":
markerColor = "rgb(239, 68, 68)";
iconName = "first-aid";
break;
case "food-bank":
markerColor = "rgb(217, 70, 239)";
iconName = "bowl-food";
break;
case "school":
markerColor = "rgb(6, 182, 212)";
iconName = "graduation-cap";
break;
case "flag":
markerColor = "rgb(244, 63, 94)";
iconName = "flag";
break;
case "protected":
markerColor = "rgb(4, 120, 87)";
iconName = "tree";
break;
case "registry":
markerColor = "rgb(29, 78, 216)";
iconName = "bank";
break;
case "charging-station": {
iconName = "charging-station";
const evType = m.meta?.poi_type ?? "";
if (evType === "DC Fast") {
markerColor = "#10b981"; // Emerald
} else if (evType === "Supercharger") {
markerColor = "#14b8a6"; // Teal
} else if (evType === "AC Level 2") {
markerColor = "#3b82f6"; // Blue
} else {
markerColor = "rgb(16, 185, 129)"; // Default green
}
break;
}
case "gas-pump": {
iconName = "gas-pump";
const brand = m.meta?.poi_type ?? "";
if (brand === "Pertamina") {
markerColor = "#ef4444"; // Red
} else if (brand === "Shell") {
markerColor = "#eab308"; // Yellow
} else if (brand === "BP") {
markerColor = "#10b981"; // Green
} else if (brand === "Vivo") {
markerColor = "#3b82f6"; // Blue
} else {
markerColor = "rgb(220, 38, 38)"; // Default red
}
break;
}
case "wrench": {
iconName = "wrench";
const spec = m.meta?.poi_type ?? "";
if (spec === "Umum") {
markerColor = "#6b7280"; // Gray
} else if (spec === "AC & Kelistrikan") {
markerColor = "#0ea5e9"; // Sky
} else if (spec === "Mesin") {
markerColor = "#f97316"; // Orange
} else if (spec === "Ban & Velg") {
markerColor = "#f59e0b"; // Amber
} else {
markerColor = "rgb(107, 114, 128)"; // Default gray
}
break;
}
}
let borderStyle = "2px solid white";
if (m.type === "gas-pump" || m.type === "charging-station" || m.type === "wrench") {
const notesLower = (m.meta?.notes ?? "").toLowerCase();
if (notesLower.includes("24 jam") || notesLower.includes("24 hours")) {
borderStyle = "2.5px solid #22c55e"; // Emerald green border for 24h
} else if (notesLower.includes("tutup") || notesLower.includes("offline") || notesLower.includes("maintenance")) {
borderStyle = "2.5px solid #ef4444"; // Red border for closed/offline
} else if (notesLower.includes("buka") || notesLower.includes("active") || notesLower.includes("open") || notesLower.includes("jam:")) {
borderStyle = "2.5px solid #3b82f6"; // Blue border for active/open/hours
}
}
const customIcon = L.divIcon({
className: "custom-user-marker",
html: `
<div style="
background-color: ${markerColor};
color: white;
width: 32px;
height: 32px;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
border: ${borderStyle};
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
">
<div style="
transform: rotate(45deg);
width: 16px;
height: 16px;
background-color: white;
mask-image: url(/light/${iconName}-light.svg);
-webkit-mask-image: url(/light/${iconName}-light.svg);
mask-size: contain;
-webkit-mask-size: contain;
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-position: center;
"></div>
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32]
});
const isDraggableMarker = !!dragDropEnabled && (m.type === "marker" || RELIGION_TYPES.has(m.type) || ["clinic", "food-bank", "school", "flag", "protected", "registry", "charging-station", "gas-pump", "wrench"].includes(m.type));
const marker = L.marker([m.lat, m.lng], {
icon: customIcon,
draggable: isDraggableMarker
}).addTo(map);
if (isDraggableMarker) {
marker.on("dragend", (e: any) => {
const newLatLng = e.target.getLatLng();
onMarkerDragEnd?.(m, newLatLng.lat, newLatLng.lng);
});
}
const tooltipHtml = buildMarkerTooltip(m);
marker.bindTooltip(tooltipHtml, {
direction: "top",
className: "user-marker-tooltip",
});
marker.on("click", (e: any) => {
e.originalEvent?.stopPropagation();
onMarkerClick?.(m);
});
layersRef.current.push(marker);
if (RELIGION_TYPES.has(m.type) && (m.meta?.radius ?? 0) > 0) {
const circle = L.circle([m.lat, m.lng], {
radius: m.meta!.radius!,
color: markerColor,
fillColor: markerColor,
fillOpacity: 0.08,
weight: 1.5,
dashArray: "5 4",
interactive: false,
}).addTo(map);
layersRef.current.push(circle);
}
});
}
}, [mode, visibilityFilters, leafletLoaded, userMarkers, dragDropEnabled]);
// 4. Handle Map Click Callback
useEffect(() => {
const map = mapInstanceRef.current;
if (!map || !onMapClick) return;
const handleMapClick = (e: any) => {
onMapClick(e.latlng.lat, e.latlng.lng);
};
map.on("click", handleMapClick);
return () => {
map.off("click", handleMapClick);
};
}, [onMapClick, leafletLoaded]);
// 5. Handle Geoman Shape Creation
useEffect(() => {
const map = mapInstanceRef.current;
if (!map || mode !== "land" || !map.pm) return;
const handlePmCreate = (e: any) => {
try {
const layer = e.layer;
const type = String(e.shape || e.layerType || "").toLowerCase();
// Remove Geoman temp layer asynchronously in the next tick to prevent event loop crashes
setTimeout(() => {
try { layer.remove(); } catch (_) { /* ignore */ }
}, 0);
const id = `shape-${Date.now()}`;
let newShape: UserMarker | null = null;
const geojson = layer.toGeoJSON();
if (type === "line" || type === "polyline") {
const geomCoords = geojson.geometry.coordinates;
if (Array.isArray(geomCoords) && geomCoords.length > 0) {
const coordinates: [number, number][] = geomCoords.map(([lng, lat]: [number, number]) => [lat, lng]);
newShape = {
id,
lat: coordinates[0][0],
lng: coordinates[0][1],
type: "line",
name: "Survey Line " + new Date().toLocaleDateString("id-ID"),
meta: { coordinates }
};
}
} else if (type === "polygon" || type === "rectangle") {
const geomCoords = geojson.geometry.coordinates[0];
if (Array.isArray(geomCoords) && geomCoords.length > 0) {
const coordinates: [number, number][] = geomCoords.map(([lng, lat]: [number, number]) => [lat, lng]);
newShape = {
id,
lat: coordinates[0][0],
lng: coordinates[0][1],
type: "polygon",
name: "Survey Area " + new Date().toLocaleDateString("id-ID"),
meta: { coordinates }
};
}
} else if (type === "circle") {
const latlng = layer.getLatLng();
const radius = layer.getRadius();
newShape = {
id,
lat: latlng.lat,
lng: latlng.lng,
type: "circle",
name: "Buffer Zone " + new Date().toLocaleDateString("id-ID"),
meta: { radius }
};
}
if (newShape && onShapeCreated) {
onShapeCreated(newShape);
}
} catch (err) {
console.error("[LeafletMap] pm:create handler error:", err);
}
};
map.on("pm:create", handlePmCreate);
return () => {
map.off("pm:create", handlePmCreate);
};
}, [leafletLoaded, mode, onShapeCreated]);
// 6. Programmatically enable Geoman drawing tools based on placingMarkerType
useEffect(() => {
const map = mapInstanceRef.current;
if (!map || mode !== "land" || !map.pm) return;
try {
map.pm.disableDraw();
if (!placingMarkerType) return;
const options = {
templineStyle: { color: "#ff3b1f" },
hintlineStyle: { color: "#ff3b1f", dashArray: "5 5" },
pathOptions: { color: "#ff3b1f", fillColor: "#ff3b1f", fillOpacity: 0.2 }
};
if (placingMarkerType === "line") {
map.pm.enableDraw("Line", options);
} else if (placingMarkerType === "polygon") {
map.pm.enableDraw("Polygon", options);
} else if (placingMarkerType === "circle") {
map.pm.enableDraw("Circle", options);
}
} catch (err) {
console.error("[LeafletMap] Geoman enableDraw error:", err);
}
}, [placingMarkerType, leafletLoaded, mode]);
return (
<div className={cn(
"relative w-full h-full min-h-[380px] bg-paper dark:bg-obsidian overflow-hidden",
!fullBleed && "rounded-lg border"
)}>
{!leafletLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-card/80 backdrop-blur-sm z-50">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-xs font-semibold text-muted-foreground">Menginisialisasi peta Leaflet...</p>
</div>
</div>
)}
<div ref={mapContainerRef} className="w-full h-full min-h-[380px]" style={{ zIndex: 1 }} />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type React from "react";
export const LogoIcon = (props: React.ComponentProps<"svg">) => (
<svg fill="currentColor" viewBox="0 -2.5 29 29" {...props}>
<path d="M 0.09375 23.921875 C 0 23.851562 -0.00390625 23.640625 0.0078125 21.367188 L 0.0234375 18.890625 L 3.324219 18.871094 L 6.628906 18.859375 L 6.664062 18.992188 C 6.683594 19.070312 6.699219 20.191406 6.699219 21.492188 C 6.699219 23.246094 6.683594 23.867188 6.628906 23.921875 C 6.523438 24.027344 0.238281 24.023438 0.09375 23.921875 Z M 0.09375 23.921875 " />
<path d="M 9.679688 23.890625 C 9.625 23.75 9.625 19.289062 9.6875 19.085938 L 9.726562 18.925781 L 11.4375 18.90625 L 13.152344 18.890625 L 13.371094 18.722656 C 13.492188 18.628906 13.632812 18.492188 13.679688 18.40625 C 13.8125 18.214844 13.851562 17.328125 13.792969 15.925781 C 13.75 14.808594 13.75 14.800781 13.5625 14.492188 C 13.460938 14.320312 13.265625 14.089844 13.121094 13.984375 L 12.875 13.792969 L 10.101562 13.792969 C 7.679688 13.789062 7.316406 13.773438 7.242188 13.699219 C 7.15625 13.613281 7.140625 13.25 7.117188 11.023438 C 7.09375 8.96875 7.070312 8.414062 7.003906 8.296875 C 6.832031 7.984375 6.570312 7.707031 6.28125 7.515625 L 5.976562 7.316406 L 3.082031 7.335938 C 0.457031 7.347656 0.179688 7.335938 0.0898438 7.246094 C 0 7.15625 -0.00390625 6.832031 0.0078125 3.640625 C 0.0195312 1.707031 0.046875 0.109375 0.0703125 0.078125 C 0.148438 0.0078125 7.121094 -0.0195312 7.195312 0.0546875 C 7.230469 0.0898438 7.265625 1.097656 7.277344 2.816406 C 7.289062 4.304688 7.3125 5.65625 7.328125 5.832031 C 7.355469 6.085938 7.414062 6.203125 7.679688 6.546875 C 8.230469 7.257812 8.042969 7.210938 10.617188 7.226562 L 12.847656 7.246094 L 13.167969 6.988281 C 13.566406 6.671875 13.859375 6.339844 13.964844 6.074219 C 14.035156 5.914062 14.046875 5.355469 14.042969 3.097656 C 14.035156 0.542969 14.042969 0.304688 14.136719 0.160156 L 14.238281 0 L 28.691406 0 L 29.0625 0.347656 L 29.429688 0.691406 L 29.441406 3.34375 C 29.46875 6.988281 29.46875 6.972656 29.253906 7.1875 C 29.128906 7.316406 29.003906 7.371094 28.796875 7.394531 C 28.640625 7.414062 26.457031 7.414062 23.941406 7.382812 C 21.429688 7.359375 18.386719 7.347656 17.175781 7.351562 L 14.980469 7.371094 L 14.753906 7.515625 C 14.503906 7.667969 14.21875 8.027344 14.082031 8.351562 C 14.019531 8.511719 14 8.988281 14 10.765625 L 14 12.980469 L 14.207031 13.292969 C 14.417969 13.609375 14.753906 13.875 15.128906 14.03125 C 15.300781 14.101562 15.910156 14.117188 18.640625 14.117188 C 21.269531 14.117188 21.964844 14.132812 22.023438 14.191406 C 22.085938 14.253906 22.109375 15.191406 22.121094 18.902344 C 22.132812 23.0625 22.125 23.546875 22.035156 23.675781 C 21.828125 24.003906 22.019531 23.992188 15.632812 23.992188 C 10.058594 23.992188 9.722656 23.988281 9.679688 23.890625 Z M 9.679688 23.890625 " />
</svg>
);
export const Logo = (props: React.ComponentProps<"svg">) => (
<svg
fill="currentColor"
viewBox="0 0 114 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M 0.09375 23.921875 C 0 23.851562 -0.00390625 23.640625 0.0078125 21.367188 L 0.0234375 18.890625 L 3.324219 18.871094 L 6.628906 18.859375 L 6.664062 18.992188 C 6.683594 19.070312 6.699219 20.191406 6.699219 21.492188 C 6.699219 23.246094 6.683594 23.867188 6.628906 23.921875 C 6.523438 24.027344 0.238281 24.023438 0.09375 23.921875 Z M 0.09375 23.921875 " />
<path d="M 9.679688 23.890625 C 9.625 23.75 9.625 19.289062 9.6875 19.085938 L 9.726562 18.925781 L 11.4375 18.90625 L 13.152344 18.890625 L 13.371094 18.722656 C 13.492188 18.628906 13.632812 18.492188 13.679688 18.40625 C 13.8125 18.214844 13.851562 17.328125 13.792969 15.925781 C 13.75 14.808594 13.75 14.800781 13.5625 14.492188 C 13.460938 14.320312 13.265625 14.089844 13.121094 13.984375 L 12.875 13.792969 L 10.101562 13.792969 C 7.679688 13.789062 7.316406 13.773438 7.242188 13.699219 C 7.15625 13.613281 7.140625 13.25 7.117188 11.023438 C 7.09375 8.96875 7.070312 8.414062 7.003906 8.296875 C 6.832031 7.984375 6.570312 7.707031 6.28125 7.515625 L 5.976562 7.316406 L 3.082031 7.335938 C 0.457031 7.347656 0.179688 7.335938 0.0898438 7.246094 C 0 7.15625 -0.00390625 6.832031 0.0078125 3.640625 C 0.0195312 1.707031 0.046875 0.109375 0.0703125 0.078125 C 0.148438 0.0078125 7.121094 -0.0195312 7.195312 0.0546875 C 7.230469 0.0898438 7.265625 1.097656 7.277344 2.816406 C 7.289062 4.304688 7.3125 5.65625 7.328125 5.832031 C 7.355469 6.085938 7.414062 6.203125 7.679688 6.546875 C 8.230469 7.257812 8.042969 7.210938 10.617188 7.226562 L 12.847656 7.246094 L 13.167969 6.988281 C 13.566406 6.671875 13.859375 6.339844 13.964844 6.074219 C 14.035156 5.914062 14.046875 5.355469 14.042969 3.097656 C 14.035156 0.542969 14.042969 0.304688 14.136719 0.160156 L 14.238281 0 L 28.691406 0 L 29.0625 0.347656 L 29.429688 0.691406 L 29.441406 3.34375 C 29.46875 6.988281 29.46875 6.972656 29.253906 7.1875 C 29.128906 7.316406 29.003906 7.371094 28.796875 7.394531 C 28.640625 7.414062 26.457031 7.414062 23.941406 7.382812 C 21.429688 7.359375 18.386719 7.347656 17.175781 7.351562 L 14.980469 7.371094 L 14.753906 7.515625 C 14.503906 7.667969 14.21875 8.027344 14.082031 8.351562 C 14.019531 8.511719 14 8.988281 14 10.765625 L 14 12.980469 L 14.207031 13.292969 C 14.417969 13.609375 14.753906 13.875 15.128906 14.03125 C 15.300781 14.101562 15.910156 14.117188 18.640625 14.117188 C 21.269531 14.117188 21.964844 14.132812 22.023438 14.191406 C 22.085938 14.253906 22.109375 15.191406 22.121094 18.902344 C 22.132812 23.0625 22.125 23.546875 22.035156 23.675781 C 21.828125 24.003906 22.019531 23.992188 15.632812 23.992188 C 10.058594 23.992188 9.722656 23.988281 9.679688 23.890625 Z M 9.679688 23.890625 " />
<path d="M 28.703125 22.320312 C 27.429688 22.054688 26.429688 21.3125 26.058594 20.371094 C 25.652344 19.320312 25.664062 19.492188 25.664062 15.644531 L 25.664062 12.152344 L 25.851562 12.105469 C 26.046875 12.050781 29.984375 11.941406 30.148438 11.984375 C 30.226562 12.003906 30.25 12.128906 30.28125 12.636719 C 30.304688 12.976562 30.332031 13.285156 30.355469 13.316406 C 30.398438 13.386719 37.679688 13.386719 37.792969 13.316406 C 37.859375 13.273438 37.875 13.082031 37.863281 12.378906 C 37.851562 11.398438 37.800781 11.238281 37.425781 10.96875 C 37.234375 10.832031 37.234375 10.832031 34.851562 10.832031 C 33.539062 10.832031 32.445312 10.808594 32.417969 10.785156 C 32.394531 10.753906 32.363281 9.957031 32.351562 9 C 32.339844 7.652344 32.351562 7.25 32.40625 7.214844 C 32.445312 7.191406 33.835938 7.167969 35.484375 7.164062 C 38.652344 7.15625 39.199219 7.191406 40.023438 7.453125 C 41.371094 7.871094 42.136719 8.703125 42.421875 10.058594 C 42.523438 10.527344 42.542969 10.878906 42.554688 12.863281 L 42.566406 15.128906 L 42.40625 15.519531 C 42.167969 16.089844 41.90625 16.335938 41.269531 16.542969 C 41.160156 16.582031 39.171875 16.597656 35.769531 16.59375 L 30.445312 16.585938 L 30.410156 16.789062 C 30.386719 16.902344 30.382812 17.296875 30.394531 17.667969 L 30.410156 18.328125 L 30.683594 18.589844 C 30.902344 18.800781 31.011719 18.855469 31.265625 18.890625 C 31.4375 18.914062 34.007812 18.925781 36.972656 18.914062 C 41.722656 18.890625 42.382812 18.902344 42.429688 18.980469 C 42.464844 19.027344 42.488281 19.773438 42.488281 20.6875 C 42.488281 22.171875 42.480469 22.316406 42.386719 22.359375 C 42.328125 22.386719 39.34375 22.417969 35.753906 22.421875 C 29.574219 22.433594 29.195312 22.429688 28.703125 22.320312 Z M 28.703125 22.320312 " />
<path d="M 56.488281 22.398438 C 56.261719 22.382812 56.203125 22.351562 56.171875 22.246094 C 56.15625 22.171875 56.136719 19.996094 56.132812 17.398438 C 56.125 14.808594 56.109375 12.28125 56.085938 11.789062 L 56.050781 10.890625 L 55.8125 10.832031 C 55.679688 10.804688 55.539062 10.75 55.503906 10.726562 C 55.445312 10.691406 55.425781 10.277344 55.425781 9.007812 L 55.425781 7.339844 L 55.59375 7.339844 C 55.679688 7.339844 55.816406 7.300781 55.890625 7.246094 C 56.019531 7.15625 56.019531 7.144531 56.019531 5.382812 C 56.019531 4.058594 56.042969 3.503906 56.109375 3.191406 C 56.28125 2.371094 56.839844 1.5 57.414062 1.15625 C 57.847656 0.90625 58.625 0.675781 59.285156 0.597656 C 59.996094 0.511719 65.769531 0.53125 65.851562 0.613281 C 65.929688 0.699219 65.949219 4.230469 65.867188 4.332031 C 65.8125 4.40625 65.402344 4.417969 63.53125 4.417969 C 61.070312 4.417969 61.1875 4.398438 60.914062 4.769531 C 60.773438 4.972656 60.765625 5 60.765625 5.992188 C 60.765625 6.554688 60.792969 7.042969 60.816406 7.078125 C 60.84375 7.125 61.554688 7.15625 63.0625 7.179688 C 64.28125 7.199219 65.308594 7.234375 65.34375 7.257812 C 65.46875 7.335938 65.332031 10.714844 65.195312 10.84375 C 65.171875 10.875 64.179688 10.898438 62.992188 10.910156 L 60.839844 10.929688 L 60.796875 13.359375 C 60.773438 14.695312 60.765625 17.242188 60.785156 19.023438 C 60.816406 21.863281 60.808594 22.273438 60.730469 22.351562 C 60.65625 22.429688 60.359375 22.441406 58.703125 22.429688 C 57.632812 22.421875 56.636719 22.410156 56.488281 22.398438 Z M 56.488281 22.398438 " />
<path d="M 69.371094 22.320312 C 68.445312 22.121094 67.640625 21.726562 67.234375 21.265625 C 66.945312 20.929688 66.566406 20.171875 66.421875 19.636719 C 66.328125 19.285156 66.316406 18.859375 66.316406 14.921875 C 66.316406 10.648438 66.316406 10.589844 66.445312 10.027344 C 66.714844 8.910156 67.332031 8.007812 68.0625 7.675781 C 68.59375 7.4375 69.273438 7.289062 70.027344 7.257812 C 71.484375 7.1875 78.792969 7.164062 79.515625 7.222656 C 80.472656 7.300781 81.105469 7.492188 81.6875 7.882812 C 82.226562 8.25 82.488281 8.546875 82.792969 9.144531 C 83.207031 9.980469 83.230469 10.160156 83.230469 12.890625 C 83.230469 15.230469 83.226562 15.316406 83.105469 15.582031 C 82.875 16.066406 82.335938 16.507812 81.878906 16.570312 C 81.742188 16.585938 79.238281 16.609375 76.316406 16.617188 L 71.003906 16.621094 L 70.988281 17.339844 C 70.980469 17.730469 70.988281 18.121094 71.003906 18.199219 C 71.058594 18.40625 71.484375 18.789062 71.707031 18.832031 C 71.8125 18.84375 74.382812 18.867188 77.425781 18.878906 C 80.472656 18.882812 82.980469 18.914062 83.003906 18.945312 C 83.027344 18.96875 83.058594 19.71875 83.070312 20.613281 C 83.089844 22.066406 83.082031 22.257812 82.992188 22.347656 C 82.902344 22.433594 82.351562 22.441406 76.386719 22.433594 C 70.335938 22.429688 69.839844 22.421875 69.371094 22.320312 Z M 78.441406 13.296875 C 78.542969 13.195312 78.535156 11.542969 78.433594 11.339844 C 78.394531 11.261719 78.246094 11.113281 78.109375 11.019531 L 77.855469 10.832031 L 74.726562 10.851562 L 71.597656 10.863281 L 71.332031 11.03125 C 71.183594 11.121094 71.035156 11.261719 70.992188 11.339844 C 70.910156 11.5 70.890625 12.308594 70.957031 12.96875 L 70.992188 13.371094 L 74.679688 13.371094 C 77.472656 13.371094 78.386719 13.351562 78.441406 13.296875 Z M 78.441406 13.296875 " />
<path d="M 85.308594 22.398438 C 85.148438 22.386719 84.980469 22.347656 84.941406 22.308594 C 84.875 22.261719 84.863281 21.082031 84.878906 16.269531 C 84.898438 9.628906 84.878906 9.925781 85.308594 9.042969 C 85.710938 8.195312 86.3125 7.710938 87.367188 7.390625 C 87.792969 7.257812 87.867188 7.257812 91.640625 7.234375 C 95.398438 7.214844 95.480469 7.214844 95.539062 7.335938 C 95.617188 7.476562 95.539062 8.300781 95.335938 9.488281 C 95.253906 9.96875 95.1875 10.4375 95.1875 10.535156 C 95.1875 10.636719 95.148438 10.777344 95.09375 10.851562 L 95.003906 10.980469 L 92.703125 10.980469 C 91.433594 10.980469 90.316406 11 90.222656 11.019531 C 90.125 11.035156 89.914062 11.167969 89.753906 11.308594 L 89.460938 11.566406 L 89.425781 16.921875 C 89.402344 20.996094 89.375 22.285156 89.320312 22.339844 C 89.265625 22.394531 88.773438 22.410156 87.425781 22.417969 C 86.421875 22.417969 85.472656 22.410156 85.308594 22.398438 Z M 85.308594 22.398438 " />
<path d="M 99.632812 22.351562 C 98.210938 22.089844 97.1875 21.300781 96.738281 20.113281 C 96.394531 19.21875 96.371094 18.832031 96.386719 14.527344 L 96.410156 10.652344 L 96.566406 10.0625 C 96.75 9.394531 97.113281 8.671875 97.433594 8.339844 C 97.871094 7.882812 98.511719 7.566406 99.4375 7.363281 C 99.855469 7.269531 100.410156 7.257812 104.597656 7.226562 L 109.285156 7.191406 L 109.300781 3.898438 C 109.3125 0.835938 109.320312 0.597656 109.421875 0.542969 C 109.582031 0.460938 113.820312 0.453125 113.917969 0.542969 C 113.992188 0.597656 114.003906 1.929688 113.996094 9.214844 C 113.996094 13.941406 113.972656 18.179688 113.945312 18.621094 C 113.882812 19.539062 113.789062 19.964844 113.492188 20.574219 C 113.078125 21.445312 112.414062 21.964844 111.300781 22.273438 C 110.828125 22.410156 110.828125 22.410156 105.457031 22.421875 C 101.066406 22.429688 100.003906 22.417969 99.632812 22.351562 Z M 108.898438 18.632812 C 108.980469 18.574219 109.105469 18.429688 109.183594 18.324219 L 109.3125 18.125 L 109.300781 14.570312 L 109.285156 11.011719 L 101.527344 11.011719 L 101.265625 11.296875 L 101.003906 11.589844 L 101.003906 14.734375 C 101.003906 17.882812 101.003906 17.882812 101.140625 18.144531 C 101.214844 18.289062 101.375 18.492188 101.5 18.585938 L 101.730469 18.769531 L 104.941406 18.800781 C 106.707031 18.820312 108.289062 18.8125 108.453125 18.789062 C 108.613281 18.765625 108.816406 18.691406 108.898438 18.632812 Z M 108.898438 18.632812 " />
<path d="M 44.839844 22.011719 C 44.8125 21.804688 44.796875 19.238281 44.785156 16.304688 C 44.773438 11.644531 44.761719 10.96875 44.683594 10.921875 C 44.636719 10.890625 44.371094 10.863281 44.089844 10.863281 C 43.8125 10.863281 43.542969 10.832031 43.496094 10.804688 C 43.417969 10.753906 43.402344 10.488281 43.390625 9.125 C 43.378906 8.238281 43.382812 7.441406 43.402344 7.363281 C 43.4375 7.234375 43.460938 7.226562 44.109375 7.210938 L 44.773438 7.191406 L 44.808594 5.402344 C 44.832031 4.417969 44.875 3.460938 44.914062 3.28125 C 45.230469 1.773438 45.996094 0.992188 47.472656 0.675781 C 47.925781 0.578125 48.328125 0.566406 51.3125 0.566406 C 53.148438 0.566406 54.667969 0.585938 54.683594 0.609375 C 54.707031 0.632812 54.730469 1.492188 54.738281 2.519531 L 54.742188 4.386719 L 52.328125 4.417969 L 49.917969 4.445312 L 49.765625 4.589844 C 49.675781 4.671875 49.582031 4.847656 49.542969 4.976562 C 49.457031 5.300781 49.527344 7.109375 49.628906 7.179688 C 49.667969 7.203125 50.179688 7.222656 50.765625 7.214844 C 51.355469 7.210938 52.328125 7.210938 52.925781 7.214844 L 54.011719 7.222656 L 53.988281 8.945312 C 53.972656 10.273438 53.949219 10.691406 53.890625 10.765625 C 53.816406 10.851562 53.566406 10.863281 51.679688 10.863281 C 50.507812 10.863281 49.542969 10.867188 49.539062 10.878906 C 49.527344 10.886719 49.507812 13.472656 49.492188 16.621094 L 49.460938 22.351562 L 47.164062 22.371094 L 44.875 22.382812 Z M 44.839844 22.011719 " />
</svg>
);
@@ -0,0 +1,35 @@
import { CosmicButton } from "@/components/ui/cosmic-button";
import { MapIcon } from "@/components/ui/phosphor-icons";
interface MapPageHeaderProps {
title: string;
description: string;
onOpenMap: () => void;
buttonText: string;
icon?: React.ReactNode;
children?: React.ReactNode;
}
export function MapPageHeader({ title, description, onOpenMap, buttonText, icon, children }: MapPageHeaderProps) {
return (
<div className="flex flex-col justify-between gap-4 border-b pb-5 md:flex-row md:items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
<p className="text-muted-foreground">{description}</p>
</div>
<div className="flex items-center gap-3">
<CosmicButton
as="button"
size="sm"
onClick={onOpenMap}
>
<span className="flex items-center gap-1.5">
{icon ?? <MapIcon className="h-4 w-4" />}
<span className="capitalize">{buttonText}</span>
</span>
</CosmicButton>
{children}
</div>
</div>
);
}
@@ -0,0 +1,82 @@
"use client";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@/components/ui/breadcrumb";
import { HouseIcon, GlobeHemisphereWestIcon, CaretDoubleRightIcon } from "@/components/ui/phosphor-icons";
import type { ReactNode } from "react";
interface MapsBreadcrumbProps {
currentPage: string;
currentIcon?: ReactNode;
isLeafletOpen?: boolean;
onExitMap?: () => void;
}
export function MapsBreadcrumb({ currentPage, currentIcon, isLeafletOpen = false, onExitMap }: MapsBreadcrumbProps) {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/dashboard" className="flex items-center gap-1">
<HouseIcon className="size-3.5" />
Beranda
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>
<CaretDoubleRightIcon />
</BreadcrumbSeparator>
{!isLeafletOpen ? (
<BreadcrumbItem>
<BreadcrumbPage className="flex items-center gap-1">
{currentIcon}
{currentPage}
</BreadcrumbPage>
</BreadcrumbItem>
) : (
<>
<BreadcrumbItem>
{onExitMap ? (
<BreadcrumbLink
render={<button type="button" onClick={onExitMap} />}
className="flex items-center gap-1"
>
{currentIcon}
{currentPage}
</BreadcrumbLink>
) : (
<BreadcrumbLink
href={
currentPage === "Peta Kemiskinan" || currentPage === "Poverty" ? "/dashboard/poverty" :
currentPage === "Lahan dan Jalan" || currentPage === "Lands and Roads" ? "/dashboard/land" :
currentPage === "SPBU dan Charger" || currentPage === "Gas Stations" ? "/dashboard/gas-station" :
"/dashboard"
}
className="flex items-center gap-1"
>
{currentIcon}
{currentPage}
</BreadcrumbLink>
)}
</BreadcrumbItem>
<BreadcrumbSeparator>
<CaretDoubleRightIcon />
</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbPage className="flex items-center gap-1">
<GlobeHemisphereWestIcon className="size-3.5" />
Peta
</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { cn } from "@/lib/utils";
import React from "react";
import { Button } from "@/components/ui/button";
import { Portal, PortalBackdrop } from "@/components/portal";
import { navLinks } from "@/components/header";
import { XIcon, MenuIcon } from "lucide-react";
export function MobileNav() {
const [open, setOpen] = React.useState(false);
return (
<div className="md:hidden">
<Button
aria-controls="mobile-menu"
aria-expanded={open}
aria-label="Toggle menu"
className="md:hidden"
onClick={() => setOpen(!open)}
size="icon"
variant="outline"
>
{open ? (
<XIcon className="size-4.5" />
) : (
<MenuIcon className="size-4.5" />
)}
</Button>
{open && (
<Portal className="top-14" id="mobile-menu">
<PortalBackdrop />
<div
className={cn(
"data-[slot=open]:zoom-in-97 ease-out data-[slot=open]:animate-in",
"size-full p-4"
)}
data-slot={open ? "open" : "closed"}
>
<div className="grid gap-y-2">
{navLinks.map((link) => (
<Button className="justify-start" key={link.label} variant="ghost" render={<a href={link.href} />} nativeButton={false}>{link.label}</Button>
))}
</div>
<div className="mt-12 flex flex-col gap-2">
<Button className="w-full" variant="outline">
Sign In
</Button>
<Button className="w-full">Get Started</Button>
</div>
</div>
</Portal>
)}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
import type { SidebarNavGroup } from "@/components/app-shared";
import { ChevronRightIcon } from "@/components/ui/phosphor-icons";
export function NavGroup({ label, items }: SidebarNavGroup) {
return (
<SidebarGroup>
{label && <SidebarGroupLabel>{label}</SidebarGroupLabel>}
<SidebarMenu>
{items.map((item) => (
<Collapsible className="group/collapsible" defaultOpen={
!!item.isActive ||
item.subItems?.some((i) => !!i.isActive)
} key={item.title} render={<SidebarMenuItem />}>{item.subItems?.length ? (
<>
<CollapsibleTrigger render={<SidebarMenuButton isActive={item.isActive} tooltip={item.title} />}>{item.icon}<span>{item.title}</span><ChevronRightIcon className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" /></CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.subItems?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton isActive={subItem.isActive} render={<a href={subItem.path} />}>{subItem.icon}<span>{subItem.title}</span></SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : (
<SidebarMenuButton isActive={item.isActive} tooltip={item.title} render={<a href={item.path} />}>{item.icon}<span>{item.title}</span></SidebarMenuButton>
)}</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import {
Avatar,
AvatarFallback,
} from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SettingsIcon, LogOutIcon } from "@/components/ui/phosphor-icons";
import { useAuth } from "@/lib/auth-context";
export function NavUser() {
const { user, logout } = useAuth();
const displayName = user?.username ?? "User";
const displayEmail = user?.email ?? "";
const initials = displayName.charAt(0).toUpperCase();
return (
<DropdownMenu>
<DropdownMenuTrigger nativeButton={false} render={<Avatar className="size-8" />}>
<AvatarFallback>{initials}</AvatarFallback>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuItem className="flex items-center justify-start gap-2">
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div>
<span className="font-medium text-foreground">{displayName}</span>
<br />
<div className="max-w-full overflow-hidden overflow-ellipsis whitespace-nowrap text-muted-foreground text-xs">
{displayEmail}
</div>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<SettingsIcon />
Pengaturan
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
className="w-full cursor-pointer"
variant="destructive"
onClick={logout}
>
<LogOutIcon />
Keluar
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { cn } from "@/lib/utils";
import React from "react";
import { createPortal } from "react-dom";
function Portal({ className, ...props }: React.ComponentProps<"div">) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
const originalStyle = window.getComputedStyle(document.body).overflow;
const scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth;
const originalPaddingRight = document.body.style.paddingRight;
document.body.style.overflow = "hidden";
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
return () => {
document.body.style.overflow = originalStyle;
document.body.style.paddingRight = originalPaddingRight;
};
}, []);
if (!mounted) {
return null;
}
return createPortal(
<div
className={cn("fixed inset-0 isolate z-40 flex flex-col", className)}
{...props}
/>,
document.body
);
}
function PortalBackdrop({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 -z-1 bg-background/95 backdrop-blur-sm duration-500 data-[state=closed]:animate-out data-[state=open]:animate-in supports-backdrop-filter:bg-background/60",
className
)}
{...props}
/>
);
}
export { Portal, PortalBackdrop };
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { DashboardCard } from "@/components/dashboard-card";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
const LABELS = [
"SANGAT TINGGI",
"TINGGI",
"SEDANG",
"RENDAH",
"TIDAK PRIORITAS",
] as const;
const LABEL_STYLES: Record<string, { bar: string; text: string; dot: string }> = {
"SANGAT TINGGI": { bar: "bg-red-500", text: "text-red-700", dot: "bg-red-500" },
"TINGGI": { bar: "bg-orange-500", text: "text-orange-700", dot: "bg-orange-500" },
"SEDANG": { bar: "bg-amber-500", text: "text-amber-700", dot: "bg-amber-500" },
"RENDAH": { bar: "bg-emerald-500", text: "text-emerald-700", dot: "bg-emerald-500" },
"TIDAK PRIORITAS": { bar: "bg-blue-500", text: "text-blue-700", dot: "bg-blue-500" },
};
export function FuzzyPriorityStats({
refreshKey,
onRecomputed,
}: {
refreshKey?: number;
onRecomputed?: () => void;
}) {
const [stats, setStats] = useState<Record<string, number> | null>(null);
const [recomputing, setRecomputing] = useState(false);
const [recomputeMsg, setRecomputeMsg] = useState<string | null>(null);
useEffect(() => {
fetch(`${BACKEND_URL}/households/stats-fuzzy`)
.then((r) => r.json())
.then(setStats)
.catch(() => setStats(null));
}, [refreshKey]);
async function handleRecompute() {
setRecomputing(true);
setRecomputeMsg(null);
try {
const token = localStorage.getItem("auth_token");
const res = await fetch(`${BACKEND_URL}/households/recompute-fuzzy`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menghitung ulang");
}
const data = await res.json();
setRecomputeMsg(`${data.updated} dari ${data.total} warga dihitung ulang`);
onRecomputed?.();
} catch (err) {
setRecomputeMsg(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setRecomputing(false);
}
}
if (!stats) return null;
const total = LABELS.reduce((sum, l) => sum + (stats[l] ?? 0), 0);
if (total === 0) return null;
return (
<div className="md:col-span-2 lg:col-span-4 flex flex-col h-full">
<DashboardCard className="gap-0 h-full py-0">
<CardHeader className="border-b pt-4 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base">Distribusi Prioritas Fuzzy</CardTitle>
<CardDescription>
{recomputeMsg ?? `${total} warga teranalisis oleh sistem fuzzy`}
</CardDescription>
</div>
<button
type="button"
onClick={handleRecompute}
disabled={recomputing}
className="h-8 shrink-0 rounded-full border border-border px-4 text-xs font-medium transition-colors hover:bg-muted disabled:opacity-50"
>
{recomputing ? "Menghitung…" : "Hitung Ulang Semua"}
</button>
</CardHeader>
<CardContent className="px-0 py-0 flex-1 flex flex-col">
<div className="grid grid-cols-5 divide-x flex-1">
{LABELS.map((label) => {
const count = stats[label] ?? 0;
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
const style = LABEL_STYLES[label];
return (
<div key={label} className="flex flex-col items-center gap-2 px-4 py-5">
<div className="flex items-center gap-1.5">
<span className={`h-2 w-2 rounded-full shrink-0 ${style.dot}`} />
<span className={`text-[11px] font-medium leading-tight text-center ${style.text}`}>
{label}
</span>
</div>
<span className="text-2xl font-bold tabular-nums">{count}</span>
<div className="w-full h-1.5 rounded-full bg-border overflow-hidden">
<div
className={`h-full rounded-full transition-all ${style.bar}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums">{pct}%</span>
</div>
);
})}
</div>
</CardContent>
</DashboardCard>
</div>
);
}
@@ -0,0 +1,138 @@
"use client";
import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { UserMarker } from "@/components/leaflet-map";
export const FUZZY_BADGE: Record<string, { bg: string; text: string; bar: string }> = {
"SANGAT TINGGI": { bg: "#fef2f2", text: "#b91c1c", bar: "#ef4444" },
"TINGGI": { bg: "#fff7ed", text: "#c2410c", bar: "#f97316" },
"SEDANG": { bg: "#fffbeb", text: "#b45309", bar: "#f59e0b" },
"RENDAH": { bg: "#f0fdf4", text: "#15803d", bar: "#10b981" },
"TIDAK PRIORITAS": { bg: "#eff6ff", text: "#1d4ed8", bar: "#6b7280" },
};
const FACTOR_LABELS: Record<string, string> = {
penghasilan: "Penghasilan",
tanggungan: "Tanggungan",
kondisi_rumah: "Kondisi Rumah",
kepemilikan_aset: "Kepemilikan Aset",
};
interface FuzzyResultStepProps {
marker: UserMarker;
confirmLabel: string;
onConfirm: () => void;
}
export function FuzzyResultStep({ marker, confirmLabel, onConfirm }: FuzzyResultStepProps) {
const label = marker.meta?.fuzzy_label ?? "";
const score = marker.meta?.fuzzy_score ?? null;
const detail = marker.meta?.fuzzy_detail ?? null;
const badge = FUZZY_BADGE[label] ?? { bg: "#f3f4f6", text: "#374151", bar: "#6b7280" };
return (
<div className="flex flex-col">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">
Hasil Analisis Fuzzy
</DialogTitle>
<p className="text-center text-[11px] text-muted-foreground mt-0.5">
{marker.name}
</p>
</DialogHeader>
<div className="flex flex-col gap-5 px-6 py-6">
{/* Priority badge */}
<div className="flex flex-col items-center gap-2">
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
Tingkat Prioritas
</span>
<span
className="px-5 py-1.5 rounded-full text-sm font-bold"
style={{ backgroundColor: badge.bg, color: badge.text }}
>
{label || "—"}
</span>
</div>
{/* Score bar */}
{score != null && (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">Skor Fuzzy</span>
<span className="text-sm font-bold tabular-nums">{score.toFixed(1)}</span>
</div>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${score}%`, backgroundColor: badge.bar }}
/>
</div>
<div className="flex justify-between text-[9px] text-muted-foreground">
<span>0</span>
<span>100</span>
</div>
</div>
)}
{/* Factor breakdown — dominant fuzzy category per input */}
{detail && (
<div className="flex flex-col gap-2">
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
Faktor Penilaian
</span>
<div className="rounded-xl border border-border divide-y divide-border overflow-hidden">
{Object.entries(FACTOR_LABELS).map(([key, name]) => {
const f = detail[key];
if (!f) return null;
return (
<div key={key} className="flex items-center justify-between px-4 py-2 text-[11px]">
<span className="text-muted-foreground">{name}</span>
<span className="flex items-center gap-2">
<span className="font-medium">{f.kategori}</span>
<span className="w-12 h-1 rounded-full bg-muted overflow-hidden">
<span
className="block h-full rounded-full"
style={{ width: `${Math.round(f.derajat * 100)}%`, backgroundColor: badge.bar }}
/>
</span>
</span>
</div>
);
})}
</div>
</div>
)}
{/* Summary grid */}
<div className="rounded-xl bg-muted/50 border border-border px-4 py-3 grid grid-cols-2 gap-y-2 gap-x-6 text-[11px]">
<span className="text-muted-foreground">Kemiskinan</span>
<span className="font-medium text-right">{marker.meta?.poverty_level ?? "—"}</span>
<span className="text-muted-foreground">Tanggungan</span>
<span className="font-medium text-right">{marker.meta?.family_count ?? "—"} jiwa</span>
<span className="text-muted-foreground">Penghasilan</span>
<span className="font-medium text-right">
Rp {(marker.meta?.penghasilan ?? 0).toLocaleString("id-ID")}/bln
</span>
<span className="text-muted-foreground">Koordinat</span>
<span className="font-mono text-right text-[10px]">
{marker.lat.toFixed(5)}, {marker.lng.toFixed(5)}
</span>
</div>
</div>
<div className="flex justify-end px-6 py-4 border-t">
<button
type="button"
onClick={onConfirm}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90"
>
{confirmLabel}
</button>
</div>
</div>
);
}
@@ -0,0 +1,473 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import { PhosphorIcon } from "@/components/ui/collection-grid-disclosure";
import { ChevronDownIcon } from "@/components/ui/phosphor-icons";
import { cn } from "@/lib/utils";
import { RadiusSlider } from "@/components/poverty/radius-slider";
import type { UserMarker } from "@/components/leaflet-map";
import { TimedUndoAction } from "@/components/time-undo-action";
import { FuzzyResultStep } from "@/components/poverty/fuzzy-result-step";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
const RELIGION_OPTIONS = [
{ value: "mosque", label: "Masjid (Islam)", icon: "mosque" },
{ value: "church", label: "Gereja (Kristen)", icon: "church" },
{ value: "cathedral", label: "Gereja Katolik", icon: "crown-cross" },
{ value: "temple", label: "Pura (Hindu)", icon: "hands-praying" },
{ value: "vihara", label: "Vihara (Buddha)", icon: "hands-praying" },
{ value: "klenteng", label: "Klenteng (Konghucu)", icon: "hands-praying" },
{ value: "synagogue", label: "Sinagog (Yahudi)", icon: "synagogue" },
];
const MARKER_LABELS: Record<string, string> = {
marker: "Warga Miskin",
mosque: "Masjid",
church: "Gereja",
cathedral: "Gereja Katolik",
temple: "Pura",
vihara: "Vihara",
klenteng: "Klenteng",
synagogue: "Sinagog",
clinic: "Klinik",
"food-bank": "Bank Makanan",
school: "Sekolah",
};
function Field({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={`flex flex-col gap-1.5 ${className ?? ""}`}>
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
function AdaptiveSlider({ value, onChange, hint }: { value: number; onChange: (v: number) => void; hint: string }) {
const pct = ((value - 1) / 9) * 100;
const color = value <= 3 ? "#ef4444" : value <= 6 ? "#f59e0b" : "#10b981";
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<input
type="range" min={1} max={10} value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{ background: `linear-gradient(to right, ${color} 0%, ${color} ${pct}%, var(--border) ${pct}%, var(--border) 100%)` }}
className="flex-1 h-[3px] cursor-pointer appearance-none rounded-full outline-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow [&::-webkit-slider-thumb]:border [&::-webkit-slider-thumb]:border-border"
/>
<span className="w-6 shrink-0 text-center text-xs font-bold tabular-nums" style={{ color }}>{value}</span>
</div>
<p className="text-[10px] text-muted-foreground">{hint}</p>
</div>
);
}
interface MarkerEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
marker: UserMarker | null;
onUpdated: (updated: UserMarker) => void;
onDeleted: (deleted: UserMarker) => void;
}
export function MarkerEditDialog({ open, onOpenChange, marker, onUpdated, onDeleted }: MarkerEditDialogProps) {
const isHousehold = marker?.id.startsWith("hh-");
const isPOI = marker?.id.startsWith("poi-");
const isReligion = isPOI && RELIGION_OPTIONS.some((o) => o.value === marker?.type);
// Household fields
const [headName, setHeadName] = useState("");
const [familyCount, setFamilyCount] = useState(3);
const [penghasilan, setPenghasilan] = useState(1500000);
const [kondisiRumah, setKondisiRumah] = useState(5);
const [kepemilikanAset, setKepemilikanAset] = useState(5);
const [notes, setNotes] = useState("");
// POI fields
const [placeName, setPlaceName] = useState("");
const [religionType, setReligionType] = useState<string | null>(null);
const [radiusMeters, setRadiusMeters] = useState(0);
const selectedReligion = RELIGION_OPTIONS.find((o) => o.value === religionType);
const DELETE_SECONDS = 5;
const TOAST_ID = "marker-delete";
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Post-save fuzzy result step (household only)
const [step, setStep] = useState<"form" | "result">("form");
const [savedMarker, setSavedMarker] = useState<UserMarker | null>(null);
// Delete countdown — lives here so it survives dialog close
const [deleteActive, setDeleteActive] = useState(false);
const [deleteCountdown, setDeleteCountdown] = useState(DELETE_SECONDS);
// Holds the marker being deleted so handleDelete works after dialog closes
const pendingDeleteMarker = useRef<UserMarker | null>(null);
// Reset only when a DIFFERENT non-null marker is opened — skip on dialog close (marker → null)
useEffect(() => {
if (!marker?.id) return;
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
setStep("form");
setSavedMarker(null);
}, [marker?.id]);
// Tick
useEffect(() => {
if (!deleteActive) return;
const id = setInterval(() => setDeleteCountdown((p) => Math.max(0, p - 1)), 1000);
return () => clearInterval(id);
}, [deleteActive]);
// Fire when countdown hits 0
useEffect(() => {
if (!deleteActive || deleteCountdown > 0) return;
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
toast.dismiss(TOAST_ID);
handleDelete();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteActive, deleteCountdown]);
// Show / update toast while countdown is running
useEffect(() => {
if (!deleteActive || !pendingDeleteMarker.current) {
toast.dismiss(TOAST_ID);
return;
}
const name = pendingDeleteMarker.current.name;
const pct = (deleteCountdown / DELETE_SECONDS) * 100;
toast.custom(
() => (
<div className="relative flex items-center gap-3 w-72 rounded-xl border border-border bg-popover px-4 py-3 shadow-lg overflow-hidden font-sans">
<div
className="absolute bottom-0 left-0 h-[3px] bg-red-500 transition-[width] duration-1000 ease-linear"
style={{ width: `${pct}%` }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate">Menghapus marker</p>
<p className="text-xs text-muted-foreground truncate">"{name}" dalam {deleteCountdown} detik</p>
</div>
<button
onClick={() => {
setDeleteActive(false);
setDeleteCountdown(DELETE_SECONDS);
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
}}
className="shrink-0 h-7 rounded-full border border-border bg-background px-3 text-xs font-medium hover:bg-muted transition-colors cursor-pointer"
>
Batalkan
</button>
</div>
),
{ id: TOAST_ID, duration: Infinity, position: "bottom-left" },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteActive, deleteCountdown]);
function toggleDelete() {
setDeleteActive((prev) => {
if (!prev) {
pendingDeleteMarker.current = marker;
setDeleteCountdown(DELETE_SECONDS);
} else {
pendingDeleteMarker.current = null;
toast.dismiss(TOAST_ID);
}
return !prev;
});
}
// Load existing data when dialog opens
useEffect(() => {
if (!open || !marker) return;
const dbId = marker.id.split("-").slice(1).join("-");
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {} as Record<string, string>;
setIsLoading(true);
setError(null);
if (isHousehold) {
fetch(`${BACKEND_URL}/households`, { headers })
.then((r) => r.json())
.then((rows: any[]) => {
const row = rows.find((r: any) => String(r.id) === dbId);
if (row) {
setHeadName(row.head_name ?? "");
setFamilyCount(row.family_count ?? 3);
setPenghasilan(row.penghasilan ?? 1500000);
setKondisiRumah(row.kondisi_rumah ?? 5);
setKepemilikanAset(row.kepemilikan_aset ?? 5);
setNotes(row.notes ?? "");
}
})
.catch(() => setError("Gagal memuat data"))
.finally(() => setIsLoading(false));
} else if (isPOI) {
fetch(`${BACKEND_URL}/poi/${dbId}`, { headers })
.then((r) => r.json())
.then((row: any) => {
setPlaceName(row.name ?? "");
setReligionType(row.religion_subtype ?? null);
setRadiusMeters(row.radius_meters ?? 0);
})
.catch(() => setError("Gagal memuat data"))
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
}
}, [open, marker]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!marker) return;
const dbId = marker.id.split("-").slice(1).join("-");
const token = localStorage.getItem("auth_token");
const headers = { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) };
setIsSubmitting(true);
setError(null);
try {
if (isHousehold) {
const res = await fetch(`${BACKEND_URL}/households/${dbId}`, {
method: "PUT", headers,
body: JSON.stringify({ head_name: headName, family_count: familyCount, notes: notes || null, penghasilan, kondisi_rumah: kondisiRumah, kepemilikan_aset: kepemilikanAset }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal menyimpan");
const saved = await res.json();
setSavedMarker({
...marker,
name: saved.head_name,
meta: {
poverty_level: saved.poverty_level,
family_count: saved.family_count,
penghasilan: saved.penghasilan,
fuzzy_label: saved.fuzzy_label ?? undefined,
fuzzy_score: saved.fuzzy_score != null ? parseFloat(saved.fuzzy_score) : undefined,
fuzzy_detail: saved.fuzzy_detail ?? undefined,
notes: saved.notes ?? undefined,
},
});
setStep("result");
setIsSubmitting(false);
return;
} else {
const name = placeName.trim() || (marker.name);
const body: any = { name };
if (isReligion) { body.poi_type = "religion"; body.religion_subtype = religionType; body.radius_meters = radiusMeters; }
const res = await fetch(`${BACKEND_URL}/poi/${dbId}`, {
method: "PUT", headers,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal menyimpan");
onUpdated({
...marker,
name,
...(isReligion && religionType ? { type: religionType } : {}),
...(isReligion ? { meta: { ...marker.meta, radius: radiusMeters } } : {}),
});
}
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsSubmitting(false);
}
}
async function handleDelete() {
const target = pendingDeleteMarker.current;
if (!target) return;
const dbId = target.id.split("-").slice(1).join("-");
const isTargetHousehold = target.id.startsWith("hh-");
const token = localStorage.getItem("auth_token");
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
setIsDeleting(true);
setError(null);
try {
const url = isTargetHousehold ? `${BACKEND_URL}/households/${dbId}` : `${BACKEND_URL}/poi/${dbId}`;
const res = await fetch(url, { method: "DELETE", headers });
if (!res.ok && res.status !== 204) throw new Error("Gagal menghapus");
pendingDeleteMarker.current = null;
onDeleted(target);
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsDeleting(false);
}
}
const title = isHousehold
? `Edit ${MARKER_LABELS["marker"]}`
: `Edit ${MARKER_LABELS[marker?.type ?? ""] ?? marker?.type ?? "Marker"}`;
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) { onOpenChange(false); } }}>
<DialogContent showCloseButton={false} className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0">
{step === "result" && savedMarker && (
<FuzzyResultStep
marker={savedMarker}
confirmLabel="Selesai"
onConfirm={() => {
onUpdated(savedMarker);
setStep("form");
setSavedMarker(null);
onOpenChange(false);
}}
/>
)}
{step === "form" && (
<form onSubmit={handleSubmit} className="flex flex-col">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">{title}</DialogTitle>
{marker && (
<p className="text-center font-mono text-[10px] text-muted-foreground mt-0.5">
{marker.lat.toFixed(6)},&nbsp;{marker.lng.toFixed(6)}
</p>
)}
</DialogHeader>
<div className="flex flex-col gap-5 px-6 py-5">
{isLoading ? (
<p className="text-sm text-muted-foreground text-center py-4">Memuat data</p>
) : isHousehold ? (
<>
<Field label="Nama Kepala Keluarga">
<Input value={headName} onChange={(e) => setHeadName(e.target.value)} required className="bg-muted/50 border border-border focus-visible:border-ring" />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="Penghasilan / Bulan">
<div className="relative">
<span className="absolute left-3 top-2.5 text-xs text-muted-foreground font-semibold">Rp</span>
<Input
type="text"
value={penghasilan ? penghasilan.toLocaleString("id-ID") : ""}
onChange={(e) => {
const clean = e.target.value.replace(/\D/g, "");
setPenghasilan(clean ? parseInt(clean, 10) : 0);
}}
className="pl-9 bg-muted/50 border border-border focus-visible:border-ring"
/>
</div>
</Field>
<Field label="Jumlah Tanggungan (jiwa)">
<Input type="number" min={1} max={20} value={familyCount} onChange={(e) => setFamilyCount(Number(e.target.value))} className="bg-muted/50 border border-border focus-visible:border-ring" />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="Kondisi Rumah"><AdaptiveSlider value={kondisiRumah} onChange={setKondisiRumah} hint="1 = sangat buruk · 10 = sangat baik" /></Field>
<Field label="Kepemilikan Aset"><AdaptiveSlider value={kepemilikanAset} onChange={setKepemilikanAset} hint="1 = tidak ada · 10 = banyak" /></Field>
</div>
<Field label="Catatan">
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} className="min-h-[72px] bg-muted/50 border border-border focus-visible:border-ring resize-none" />
</Field>
</>
) : (
<>
{isReligion && (
<Field label="Jenis Tempat Ibadah">
<DropdownMenu>
<DropdownMenuTrigger
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm transition-colors hover:bg-muted focus-visible:border-ring focus-visible:outline-none"
>
<span className="flex min-w-0 items-center gap-2">
{selectedReligion?.icon && (
<span className="size-4 shrink-0 text-foreground">
<PhosphorIcon name={selectedReligion.icon} className="size-4" />
</span>
)}
<span className={cn("truncate", !selectedReligion && "text-muted-foreground")}>
{selectedReligion ? selectedReligion.label : "Pilih jenis…"}
</span>
</span>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-(--anchor-width)">
<DropdownMenuRadioGroup
value={religionType ?? ""}
onValueChange={setReligionType}
>
{RELIGION_OPTIONS.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
<span className="size-4 shrink-0 text-foreground">
<PhosphorIcon name={opt.icon} className="size-4" />
</span>
{opt.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</Field>
)}
<Field label="Nama Tempat">
<Input value={placeName} onChange={(e) => setPlaceName(e.target.value)} className="bg-muted/50 border border-border focus-visible:border-ring" />
</Field>
{isReligion && (
<Field label="Radius Jangkauan (meter)">
<RadiusSlider value={radiusMeters} onChange={setRadiusMeters} />
</Field>
)}
</>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<div className="flex items-center justify-between gap-2 px-6 py-4 border-t">
<TimedUndoAction
compact
deleteLabel="Hapus Marker"
undoLabel="Batal"
isDeleting={deleteActive}
countDown={deleteCountdown}
onToggle={toggleDelete}
/>
<div className="flex gap-2">
<button type="button" onClick={() => onOpenChange(false)} disabled={isSubmitting}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50">
Batal
</button>
<button type="submit" disabled={isSubmitting || isLoading || (isHousehold && !headName.trim())}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40">
{isSubmitting ? "Menyimpan…" : "Simpan"}
</button>
</div>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,393 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { UserMarker } from "@/components/leaflet-map";
import { FuzzyResultStep } from "@/components/poverty/fuzzy-result-step";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
const MARKER_LABELS: Record<string, string> = {
marker: "Warga Miskin",
mosque: "Masjid",
church: "Gereja",
synagogue: "Sinagog",
clinic: "Klinik",
"food-bank": "Bank Makanan",
school: "Sekolah",
};
interface PendingClick {
lat: number;
lng: number;
}
interface MarkerPlacementDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
markerType: string;
pendingClick: PendingClick | null;
onConfirm: (marker: UserMarker) => void;
}
/* ─── Adaptive Slider ─────────────────────────────────────── */
function AdaptiveSlider({
value,
onChange,
hint,
}: {
value: number;
onChange: (v: number) => void;
hint: string;
}) {
const pct = ((value - 1) / 9) * 100;
const color =
value <= 3 ? "#ef4444" : value <= 6 ? "#f59e0b" : "#10b981";
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<input
type="range"
min={1}
max={10}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{
background: `linear-gradient(to right, ${color} 0%, ${color} ${pct}%, var(--border) ${pct}%, var(--border) 100%)`,
}}
className="
flex-1 h-[3px] cursor-pointer appearance-none rounded-full outline-none
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:shadow
[&::-webkit-slider-thumb]:border
[&::-webkit-slider-thumb]:border-border
[&::-moz-range-thumb]:w-3
[&::-moz-range-thumb]:h-3
[&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:bg-white
[&::-moz-range-thumb]:border
[&::-moz-range-thumb]:border-border
"
/>
<span
className="w-6 shrink-0 text-center text-xs font-bold tabular-nums"
style={{ color }}
>
{value}
</span>
</div>
<p className="text-[10px] text-muted-foreground">{hint}</p>
</div>
);
}
/* ─── Field wrapper ───────────────────────────────────────── */
function Field({
label,
children,
className,
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={`flex flex-col gap-1.5 ${className ?? ""}`}>
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
/* ─── Main dialog ─────────────────────────────────────────── */
export function MarkerPlacementDialog({
open,
onOpenChange,
markerType,
pendingClick,
onConfirm,
}: MarkerPlacementDialogProps) {
const isHousehold = markerType === "marker";
const [headName, setHeadName] = useState("");
const [familyCount, setFamilyCount] = useState(3);
const [penghasilan, setPenghasilan] = useState(1500000);
const [kondisiRumah, setKondisiRumah] = useState(5);
const [kepemilikanAset, setKepemilikanAset] = useState(5);
const [notes, setNotes] = useState("");
const [placeName, setPlaceName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [step, setStep] = useState<"form" | "result">("form");
const [savedMarker, setSavedMarker] = useState<UserMarker | null>(null);
function reset() {
setHeadName(""); setFamilyCount(3); setPenghasilan(1500000);
setKondisiRumah(5); setKepemilikanAset(5);
setNotes(""); setPlaceName(""); setError(null);
setStep("form"); setSavedMarker(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!pendingClick) return;
setIsSubmitting(true);
setError(null);
try {
if (isHousehold) {
const token = localStorage.getItem("auth_token");
const res = await fetch(`${BACKEND_URL}/households`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
head_name: headName,
family_count: familyCount,
latitude: pendingClick.lat,
longitude: pendingClick.lng,
notes: notes || null,
penghasilan,
kondisi_rumah: kondisiRumah,
kepemilikan_aset: kepemilikanAset,
marker_type: "marker",
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menyimpan data");
}
const saved = await res.json();
const marker: UserMarker = {
id: `hh-${saved.id}`,
lat: pendingClick.lat,
lng: pendingClick.lng,
type: "marker",
name: saved.head_name,
meta: {
poverty_level: saved.poverty_level,
family_count: saved.family_count,
penghasilan: saved.penghasilan,
fuzzy_label: saved.fuzzy_label ?? undefined,
fuzzy_score: saved.fuzzy_score != null ? parseFloat(saved.fuzzy_score) : undefined,
fuzzy_detail: saved.fuzzy_detail ?? undefined,
notes: saved.notes ?? undefined,
},
};
setSavedMarker(marker);
setStep("result");
setIsSubmitting(false);
return;
} else {
const token = localStorage.getItem("auth_token");
const name = placeName.trim() || MARKER_LABELS[markerType] || markerType;
const res = await fetch(`${BACKEND_URL}/poi`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
name,
poi_type: markerType,
latitude: pendingClick.lat,
longitude: pendingClick.lng,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menyimpan data");
}
const saved = await res.json();
onConfirm({
id: `poi-${saved.id}`,
lat: pendingClick.lat,
lng: pendingClick.lng,
type: markerType,
name,
meta: { poi_type: markerType, notes: saved.notes ?? undefined },
});
}
reset();
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsSubmitting(false);
}
}
function handleCancel() {
reset();
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
<DialogContent
showCloseButton={false}
className="sm:max-w-lg w-full max-h-[90vh] overflow-y-auto rounded-2xl p-0 gap-0"
>
{/* ── Fuzzy Result Step ── */}
{step === "result" && savedMarker && (
<FuzzyResultStep
marker={savedMarker}
confirmLabel="Selesai & Pasang Marker"
onConfirm={() => {
onConfirm(savedMarker);
reset();
onOpenChange(false);
}}
/>
)}
{/* ── Form Step ── */}
{step === "form" && (
<form onSubmit={handleSubmit} className="flex flex-col">
{/* ── Header ── */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">
{isHousehold
? "Tambah Data Warga Miskin"
: `Tambah ${MARKER_LABELS[markerType] ?? markerType}`}
</DialogTitle>
{pendingClick && (
<p className="text-center font-mono text-[10px] text-muted-foreground mt-0.5">
{pendingClick.lat.toFixed(6)},&nbsp;{pendingClick.lng.toFixed(6)}
</p>
)}
</DialogHeader>
{/* ── Body ── */}
<div className="flex flex-col gap-5 px-6 py-5">
{isHousehold ? (
<>
{/* Row 1: Nama KK */}
<Field label="Nama Kepala Keluarga">
<Input
placeholder="cth. Budi Santoso"
value={headName}
onChange={(e) => setHeadName(e.target.value)}
required
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
{/* Row 2: Penghasilan + Tanggungan */}
<div className="grid grid-cols-2 gap-4">
<Field label="Penghasilan / Bulan">
<div className="relative">
<span className="absolute left-3 top-2.5 text-xs text-muted-foreground font-semibold">Rp</span>
<Input
type="text"
value={penghasilan ? penghasilan.toLocaleString("id-ID") : ""}
onChange={(e) => {
const clean = e.target.value.replace(/\D/g, "");
setPenghasilan(clean ? parseInt(clean, 10) : 0);
}}
className="pl-9 bg-muted/50 border border-border focus-visible:border-ring"
/>
</div>
</Field>
<Field label="Jumlah Tanggungan (jiwa)">
<Input
type="number"
min={1}
max={20}
value={familyCount}
onChange={(e) => setFamilyCount(Number(e.target.value))}
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
</div>
{/* Row 3: Sliders */}
<div className="grid grid-cols-2 gap-4">
<Field label="Kondisi Rumah">
<AdaptiveSlider
value={kondisiRumah}
onChange={setKondisiRumah}
hint="1 = sangat buruk · 10 = sangat baik"
/>
</Field>
<Field label="Kepemilikan Aset">
<AdaptiveSlider
value={kepemilikanAset}
onChange={setKepemilikanAset}
hint="1 = tidak ada · 10 = banyak"
/>
</Field>
</div>
{/* Row 4: Catatan */}
<Field label="Catatan">
<Textarea
placeholder="Informasi tambahan (opsional)"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="min-h-[72px] bg-muted/50 border border-border focus-visible:border-ring resize-none"
/>
</Field>
</>
) : (
<Field label="Nama Tempat">
<Input
placeholder={MARKER_LABELS[markerType] ?? "Nama lokasi"}
value={placeName}
onChange={(e) => setPlaceName(e.target.value)}
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
)}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
</div>
{/* ── Footer ── */}
<div className="flex justify-end gap-2 px-6 py-4 border-t">
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50"
>
Batal
</button>
<button
type="submit"
disabled={isSubmitting || (isHousehold && !headName.trim())}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40"
>
{isSubmitting ? "Menyimpan…" : "Simpan & Pasang Marker"}
</button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,82 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { SearchIcon } from "@/components/ui/phosphor-icons";
import type { HouseholdRow } from "@/lib/poverty-types";
const SCORE_COLOR: Record<string, string> = {
"SANGAT TINGGI": "text-red-600",
"TINGGI": "text-orange-600",
"SEDANG": "text-amber-600",
"RENDAH": "text-emerald-600",
"TIDAK PRIORITAS": "text-blue-600",
};
interface PovertyDirectoryProps {
searchQuery: string;
setSearchQuery: (query: string) => void;
households: HouseholdRow[];
selectedId: number | null;
onSelect: (household: HouseholdRow) => void;
}
export function PovertyDirectory({
searchQuery,
setSearchQuery,
households,
selectedId,
onSelect,
}: PovertyDirectoryProps) {
const filtered = households
.filter((h) => h.head_name.toLowerCase().includes(searchQuery.toLowerCase()))
.sort((a, b) => Number(b.fuzzy_score ?? -1) - Number(a.fuzzy_score ?? -1));
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
<CardHeader className="py-4">
<CardTitle className="text-base font-semibold">Direktori Warga Prioritas</CardTitle>
<CardDescription>Diurutkan dari skor fuzzy tertinggi. Pilih warga untuk melihat detail di panel inspektur.</CardDescription>
</CardHeader>
<CardContent className="pb-6 flex-1">
<div className="relative mb-4">
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cari nama kepala keluarga..."
className="pl-9 bg-background h-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{filtered.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground">
{households.length === 0
? "Belum ada warga terdata. Tambah marker di peta untuk memulai."
: "Tidak ada warga yang cocok dengan pencarian."}
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 max-h-[320px] overflow-y-auto pr-1">
{filtered.map((h) => {
const isSelected = selectedId === h.id;
const score = h.fuzzy_score != null ? Number(h.fuzzy_score).toFixed(1) : "—";
return (
<div
key={h.id}
onClick={() => onSelect(h)}
className={`p-3 border rounded-lg cursor-pointer flex justify-between items-center transition-all ${
isSelected
? "border-primary bg-primary/5 text-primary font-semibold"
: "border-border/60 hover:bg-muted"
}`}
>
<span className="text-xs text-foreground font-medium truncate me-2">{h.head_name}</span>
<span className={`text-xs bg-muted border px-2 py-0.5 rounded font-bold tabular-nums shrink-0 ${SCORE_COLOR[h.fuzzy_label ?? ""] ?? "text-muted-foreground"}`}>
{score}
</span>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DashboardCard } from "@/components/dashboard-card";
import { ArrowRightIcon, ArrowUpIcon } from "@/components/ui/phosphor-icons";
import { cn } from "@/lib/utils";
import type { HouseholdRow } from "@/lib/poverty-types";
const PREVIEW_COUNT = 5;
function PovertyBadge({ level }: { level: string }) {
if (level === "Extreme") {
return (
<Badge className="bg-red-100 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-800">
Ekstrem
</Badge>
);
}
if (level === "Miskin") {
return (
<Badge className="bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-800">
Miskin
</Badge>
);
}
return (
<Badge className="bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-400 dark:border-blue-800">
Rentan
</Badge>
);
}
function FuzzyPriorityBadge({ label }: { label: string | null }) {
if (!label) return <span className="text-xs text-muted-foreground"></span>;
const colors: Record<string, string> = {
"SANGAT TINGGI": "bg-red-100 text-red-700 border-red-200",
"TINGGI": "bg-orange-100 text-orange-700 border-orange-200",
"SEDANG": "bg-amber-100 text-amber-700 border-amber-200",
"RENDAH": "bg-emerald-100 text-emerald-700 border-emerald-200",
"TIDAK PRIORITAS": "bg-blue-100 text-blue-700 border-blue-200",
};
return (
<Badge className={colors[label] ?? "bg-muted text-muted-foreground"}>
{label}
</Badge>
);
}
function ScoreBar({ value }: { value: number }) {
const pct = Math.round((value / 10) * 100);
const color =
value <= 3 ? "bg-red-400" : value <= 6 ? "bg-amber-400" : "bg-emerald-400";
return (
<div className="flex items-center gap-1.5">
<div className="w-14 h-1.5 rounded-full bg-border overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs tabular-nums text-muted-foreground">{value}/10</span>
</div>
);
}
export function PovertyHouseholdsTable({
rows,
loading = false,
}: {
rows: HouseholdRow[];
loading?: boolean;
}) {
const [showAll, setShowAll] = useState(false);
const visible = showAll ? rows : rows.slice(0, PREVIEW_COUNT);
const hasMore = rows.length > PREVIEW_COUNT;
return (
<DashboardCard className="relative gap-0 h-full flex flex-col">
<CardHeader className="border-b">
<CardTitle className="text-base">Data Warga Miskin</CardTitle>
<CardDescription>
{loading ? "Memuat data…" : `${rows.length} entri tersimpan dari peta · skor prioritas fuzzy`}
</CardDescription>
</CardHeader>
<CardContent className={cn("px-0 flex-1", !showAll && hasMore && "mask-b-from-50% mask-b-to-100%")}>
{loading && (
<div className="flex items-center justify-center py-12 text-xs text-muted-foreground">
Memuat data...
</div>
)}
{!loading && rows.length === 0 && (
<div className="flex items-center justify-center py-12 text-xs text-muted-foreground">
Belum ada data. Tambah marker di peta untuk mencatat warga miskin.
</div>
)}
{!loading && rows.length > 0 && (
<Table>
<TableCaption className="sr-only">
Data warga miskin dengan skor prioritas fuzzy.
</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="ps-6 w-8">#</TableHead>
<TableHead>Kepala Keluarga</TableHead>
<TableHead className="text-center">Jiwa</TableHead>
<TableHead>Rumah</TableHead>
<TableHead>Aset</TableHead>
<TableHead>Tingkat</TableHead>
<TableHead className="text-right">Penghasilan</TableHead>
<TableHead className="text-right tabular-nums">Skor Fuzzy</TableHead>
<TableHead className="pe-6">Prioritas</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((row, i) => (
<TableRow className="h-12" key={row.id}>
<TableCell className="ps-6 text-xs text-muted-foreground tabular-nums">
{i + 1}
</TableCell>
<TableCell className="font-medium max-w-[160px] truncate">
{row.head_name}
</TableCell>
<TableCell className="text-center text-xs tabular-nums text-muted-foreground">
{row.family_count}
</TableCell>
<TableCell>
<ScoreBar value={Number(row.kondisi_rumah)} />
</TableCell>
<TableCell>
<ScoreBar value={Number(row.kepemilikan_aset)} />
</TableCell>
<TableCell>
<PovertyBadge level={row.poverty_level} />
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
Rp {Number(row.penghasilan).toLocaleString("id-ID")}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{row.fuzzy_score != null ? Number(row.fuzzy_score).toFixed(1) : "—"}
</TableCell>
<TableCell className="pe-6">
<FuzzyPriorityBadge label={row.fuzzy_label} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
{/* View All overlay — only when collapsed and there are hidden rows */}
{!showAll && hasMore && (
<div className="mask-t-from-30% absolute inset-x-0 bottom-0 flex h-1/5 items-center justify-center bg-background">
<Button variant="ghost" onClick={() => setShowAll(true)}>
Lihat Semua ({rows.length})
<ArrowRightIcon aria-hidden="true" />
</Button>
</div>
)}
{/* Collapse button — only when expanded */}
{showAll && rows.length > PREVIEW_COUNT && (
<div className="flex items-center justify-center border-t py-3">
<Button variant="ghost" onClick={() => setShowAll(false)}>
<ArrowUpIcon aria-hidden="true" />
Tutup
</Button>
</div>
)}
</DashboardCard>
);
}
@@ -0,0 +1,26 @@
const LEGEND = [
{ label: "Sangat Tinggi", color: "#ef4444" },
{ label: "Tinggi", color: "#f97316" },
{ label: "Sedang", color: "#f59e0b" },
{ label: "Rendah", color: "#10b981" },
{ label: "Tidak Prioritas", color: "#6b7280" },
];
export function PovertyHud() {
return (
<>
{/* Floating Legend — fuzzy priority marker colors */}
<div className="absolute bottom-6 left-4 z-[999] bg-background/90 backdrop-blur-md border border-border/40 p-3.5 rounded-lg shadow-lg text-xs flex flex-col gap-2">
<span className="font-bold text-foreground">Prioritas Fuzzy</span>
<div className="flex flex-col gap-1">
{LEGEND.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: item.color }} />
<span className="text-muted-foreground text-[10px]">{item.label}</span>
</div>
))}
</div>
</div>
</>
);
}
@@ -0,0 +1,90 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { HouseholdRow } from "@/lib/poverty-types";
const LABEL_BADGE: Record<string, string> = {
"SANGAT TINGGI": "bg-red-100 text-red-700 border-red-200",
"TINGGI": "bg-orange-100 text-orange-700 border-orange-200",
"SEDANG": "bg-amber-100 text-amber-700 border-amber-200",
"RENDAH": "bg-emerald-100 text-emerald-700 border-emerald-200",
"TIDAK PRIORITAS": "bg-blue-100 text-blue-700 border-blue-200",
};
interface PovertyInspectorTopProps {
selected: HouseholdRow | null;
}
export function PovertyInspectorTop({ selected }: PovertyInspectorTopProps) {
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col h-full">
<CardHeader className="border-b bg-muted/20 px-6 py-4 flex flex-col justify-center">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-primary" />
Inspektur Warga
</CardTitle>
<CardDescription>
{selected ? `Analisis untuk ${selected.head_name}.` : "Pilih warga dari direktori."}
</CardDescription>
</CardHeader>
<CardContent className="px-6 py-6 flex-1 flex flex-col justify-center">
{selected ? (
<>
<h3 className="text-2xl font-bold text-foreground truncate">{selected.head_name}</h3>
<span className={`inline-block mt-2 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border w-fit ${LABEL_BADGE[selected.fuzzy_label ?? ""] ?? "bg-muted text-muted-foreground border-border"}`}>
{selected.fuzzy_label ?? "Belum dianalisis"}
</span>
</>
) : (
<p className="text-sm text-muted-foreground">Belum ada warga dipilih.</p>
)}
</CardContent>
</Card>
);
}
interface PovertyInspectorBottomProps {
selected: HouseholdRow | null;
}
export function PovertyInspectorBottom({ selected }: PovertyInspectorBottomProps) {
const score = selected?.fuzzy_score != null ? Number(selected.fuzzy_score).toFixed(1) : "—";
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between h-full">
<CardContent className="px-0 py-6 flex flex-col justify-between flex-1">
<div className="grid grid-cols-2 gap-4 px-6">
<div className="p-3 rounded-lg border bg-muted/10">
<span className="text-xs text-muted-foreground">Skor Fuzzy</span>
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">{score}</p>
</div>
<div className="p-3 rounded-lg border bg-muted/10">
<span className="text-xs text-muted-foreground">Penghasilan</span>
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">
{selected ? `Rp ${Number(selected.penghasilan).toLocaleString("id-ID")}` : "—"}
</p>
</div>
<div className="p-3 rounded-lg border bg-muted/10">
<span className="text-xs text-muted-foreground">Tanggungan</span>
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">
{selected ? `${selected.family_count} jiwa` : "—"}
</p>
</div>
<div className="p-3 rounded-lg border bg-muted/10">
<span className="text-xs text-muted-foreground">Rumah · Aset</span>
<p className="text-xl font-bold mt-1 text-foreground tabular-nums">
{selected ? `${selected.kondisi_rumah} · ${selected.kepemilikan_aset}` : "—"}
</p>
</div>
</div>
<div className="border-t mt-6 pt-6 px-6 text-xs text-muted-foreground flex flex-col gap-2">
<p>
Skor fuzzy 0100 dihitung dari penghasilan, tanggungan, kondisi rumah, dan kepemilikan aset. Semakin tinggi skor, semakin prioritas untuk bantuan.
</p>
<p>
Kondisi rumah dan kepemilikan aset dinilai pada skala 110 saat pendataan di peta.
</p>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,71 @@
import { Card } from "@/components/ui/card";
import type { PovertyOverview } from "@/lib/poverty-types";
interface PovertyStatsCardsProps {
overview: PovertyOverview | null;
}
export function PovertyStatsCards({ overview }: PovertyStatsCardsProps) {
const totals = overview?.totals;
const byLabel = overview?.by_label ?? {};
const highPriority = (byLabel["SANGAT TINGGI"] ?? 0) + (byLabel["TINGGI"] ?? 0);
const poiTotal = Object.values(overview?.poi ?? {}).reduce((s, n) => s + n, 0);
const avgDependents =
totals && totals.household_count > 0
? (totals.total_dependents / totals.household_count).toFixed(1)
: null;
return (
<>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
<div className="px-6 pt-6 pb-4">
<span className="text-xs font-medium text-muted-foreground">Warga Terdata</span>
<div className="text-3xl font-bold text-foreground mt-2">
{totals ? totals.household_count : "—"} <span className="text-base font-semibold text-muted-foreground">KK</span>
</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
{poiTotal} fasilitas umum terpetakan
</div>
</Card>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
<div className="px-6 pt-6 pb-4">
<span className="text-xs font-medium text-muted-foreground">Total Tanggungan</span>
<div className="text-3xl font-bold text-foreground mt-2">
{totals ? totals.total_dependents : "—"} <span className="text-base font-semibold text-muted-foreground">jiwa</span>
</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
{avgDependents ? `rata-rata ${avgDependents} jiwa per KK` : "belum ada data"}
</div>
</Card>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
<div className="px-6 pt-6 pb-4">
<span className="text-xs font-medium text-muted-foreground">Rata-rata Penghasilan</span>
<div className="text-3xl font-bold text-foreground mt-2">
{totals ? `Rp ${Math.round(totals.avg_income).toLocaleString("id-ID")}` : "—"}
</div>
</div>
<div className="border-t px-6 py-3 text-xs text-muted-foreground mt-auto">
per kepala keluarga per bulan
</div>
</Card>
<Card className="bg-background rounded-none border-none shadow-none ring-0 flex flex-col justify-between">
<div className="px-6 pt-6 pb-4">
<span className="text-xs font-medium text-muted-foreground">Rata-rata Skor Fuzzy</span>
<div className="text-3xl font-bold text-foreground mt-2">
{totals?.avg_score != null ? Number(totals.avg_score).toFixed(1) : "—"}
<span className="text-base font-semibold text-muted-foreground"> / 100</span>
</div>
</div>
<div className="border-t px-6 py-3 text-xs mt-auto">
<span className="font-medium text-rose-500">{highPriority} KK</span>
<span className="text-muted-foreground"> prioritas tinggi ke atas</span>
</div>
</Card>
</>
);
}
@@ -0,0 +1,86 @@
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
import type { PovertyOverview } from "@/lib/poverty-types";
const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"];
function formatMonth(ym: string) {
const [year, month] = ym.split("-");
return `${MONTH_NAMES[Number(month) - 1] ?? month} '${year.slice(2)}`;
}
interface PovertyTrendChartProps {
monthly: PovertyOverview["monthly"];
}
export function PovertyTrendChart({ monthly }: PovertyTrendChartProps) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const chartData = monthly.map((m) => ({
month: formatMonth(m.month),
pendataan: m.count,
skor: m.avg_score != null ? Number(Number(m.avg_score).toFixed(1)) : null,
}));
return (
<Card className="bg-background rounded-none border-none shadow-none ring-0 h-full flex flex-col">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base font-semibold">Tren Pendataan Warga</CardTitle>
<CardDescription>Jumlah pendataan baru dan rata-rata skor prioritas fuzzy per bulan, dari basis data.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex-1 w-full pt-4 min-h-[320px] relative">
{chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-xs text-muted-foreground">
Belum ada data pendataan. Tambah marker warga di peta untuk memulai.
</div>
) : !isMounted ? (
<div className="h-full w-full" />
) : (
<div className="absolute inset-0 pt-4 px-6 pb-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 10, right: 15, left: -15, bottom: 0 }}>
<XAxis dataKey="month" tickLine={false} style={{ fontSize: 11 }} stroke="#999ba3" />
<YAxis tickLine={false} style={{ fontSize: 11 }} stroke="#999ba3" />
<Tooltip
contentStyle={{
background: "rgba(12, 10, 8, 0.95)",
border: "1px solid #4d505d",
borderRadius: "6px",
fontSize: "12px",
color: "#ffffff"
}}
/>
<Line
type="monotone"
dataKey="skor"
stroke="#ff3b1f"
strokeWidth={3}
dot={{ fill: "#ff3b1f", r: 4 }}
activeDot={{ r: 6 }}
name="Rata-rata Skor Fuzzy"
connectNulls
/>
<Line
type="monotone"
dataKey="pendataan"
stroke="#5683d2"
strokeWidth={2}
dot={{ fill: "#5683d2", r: 3 }}
activeDot={{ r: 5 }}
name="Pendataan Baru (KK)"
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,54 @@
"use client";
interface RadiusSliderProps {
value: number;
onChange: (value: number) => void;
}
export function RadiusSlider({ value, onChange }: RadiusSliderProps) {
const pct = (value / 2000) * 100;
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={2000}
step={50}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{
background: value === 0
? "var(--border)"
: `linear-gradient(to right, #10b981 0%, #10b981 ${pct}%, var(--border) ${pct}%, var(--border) 100%)`,
}}
className="
flex-1 h-[3px] cursor-pointer appearance-none rounded-full outline-none
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3
[&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:shadow
[&::-webkit-slider-thumb]:border
[&::-webkit-slider-thumb]:border-border
[&::-moz-range-thumb]:w-3
[&::-moz-range-thumb]:h-3
[&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:bg-white
[&::-moz-range-thumb]:border
[&::-moz-range-thumb]:border-border
"
/>
<span
className="w-12 shrink-0 text-center text-xs font-bold tabular-nums"
style={{ color: value === 0 ? "var(--muted-foreground)" : "#10b981" }}
>
{value === 0 ? "—" : `${value}m`}
</span>
</div>
<p className="text-[10px] text-muted-foreground">0 = tidak tampilkan lingkaran</p>
</div>
);
}
@@ -0,0 +1,224 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import { PhosphorIcon } from "@/components/ui/collection-grid-disclosure";
import { ChevronDownIcon } from "@/components/ui/phosphor-icons";
import { cn } from "@/lib/utils";
import { RadiusSlider } from "@/components/poverty/radius-slider";
import type { UserMarker } from "@/components/leaflet-map";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:4000";
const RELIGION_OPTIONS = [
{ value: "mosque", label: "Masjid (Islam)", icon: "mosque" },
{ value: "church", label: "Gereja (Kristen)", icon: "church" },
{ value: "cathedral", label: "Gereja Katolik", icon: "crown-cross" },
{ value: "temple", label: "Pura (Hindu)", icon: "hands-praying" },
{ value: "vihara", label: "Vihara (Buddha)", icon: "hands-praying" },
{ value: "klenteng", label: "Klenteng (Konghucu)", icon: "hands-praying" },
{ value: "synagogue", label: "Sinagog (Yahudi)", icon: "synagogue" },
];
interface PendingClick { lat: number; lng: number; }
interface ReligionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pendingClick: PendingClick | null;
onConfirm: (marker: UserMarker) => void;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<span className="text-[11px] font-semibold text-foreground/60 uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
export function ReligionDialog({
open,
onOpenChange,
pendingClick,
onConfirm,
}: ReligionDialogProps) {
const [religionType, setReligionType] = useState<string | null>(null);
const [placeName, setPlaceName] = useState("");
const [radiusMeters, setRadiusMeters] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedReligion = RELIGION_OPTIONS.find((o) => o.value === religionType);
function reset() {
setReligionType(null);
setPlaceName("");
setRadiusMeters(0);
setError(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!pendingClick || !religionType) return;
setIsSubmitting(true);
setError(null);
try {
const token = localStorage.getItem("auth_token");
const label = RELIGION_OPTIONS.find((o) => o.value === religionType)?.label ?? religionType;
const name = placeName.trim() || label;
const res = await fetch(`${BACKEND_URL}/poi`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
name,
poi_type: "religion",
religion_subtype: religionType,
latitude: pendingClick.lat,
longitude: pendingClick.lng,
radius_meters: radiusMeters,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Gagal menyimpan data");
}
const saved = await res.json();
onConfirm({
id: `poi-${saved.id}`,
lat: pendingClick.lat,
lng: pendingClick.lng,
type: religionType,
name,
meta: { poi_type: "religion", notes: saved.notes ?? undefined, radius: saved.radius_meters ?? 0 },
});
reset();
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Terjadi kesalahan");
} finally {
setIsSubmitting(false);
}
}
function handleCancel() {
reset();
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
<DialogContent
showCloseButton={false}
className="sm:max-w-sm w-full rounded-2xl p-0 gap-0"
>
<form onSubmit={handleSubmit} className="flex flex-col">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="text-center text-base font-semibold">
Tambah Tempat Ibadah
</DialogTitle>
{pendingClick && (
<p className="text-center font-mono text-[10px] text-muted-foreground mt-0.5">
{pendingClick.lat.toFixed(6)},&nbsp;{pendingClick.lng.toFixed(6)}
</p>
)}
</DialogHeader>
<div className="flex flex-col gap-5 px-6 py-5">
<Field label="Jenis Tempat Ibadah">
<DropdownMenu>
<DropdownMenuTrigger
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm transition-colors hover:bg-muted focus-visible:border-ring focus-visible:outline-none"
>
<span className="flex min-w-0 items-center gap-2">
{selectedReligion?.icon && (
<span className="size-4 shrink-0 text-foreground">
<PhosphorIcon name={selectedReligion.icon} className="size-4" />
</span>
)}
<span className={cn("truncate", !selectedReligion && "text-muted-foreground")}>
{selectedReligion ? selectedReligion.label : "Pilih jenis…"}
</span>
</span>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-(--anchor-width)">
<DropdownMenuRadioGroup
value={religionType ?? ""}
onValueChange={setReligionType}
>
{RELIGION_OPTIONS.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
<span className="size-4 shrink-0 text-foreground">
<PhosphorIcon name={opt.icon} className="size-4" />
</span>
{opt.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</Field>
<Field label="Nama Tempat Ibadah">
<Input
placeholder="cth. Masjid Al-Ikhlas"
value={placeName}
onChange={(e) => setPlaceName(e.target.value)}
className="bg-muted/50 border border-border focus-visible:border-ring"
/>
</Field>
<Field label="Radius Jangkauan (meter)">
<RadiusSlider value={radiusMeters} onChange={setRadiusMeters} />
</Field>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<div className="flex justify-end gap-2 px-6 py-4 border-t">
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="h-9 rounded-full border border-border px-5 text-sm font-medium transition-colors hover:bg-muted disabled:opacity-50"
>
Batal
</button>
<button
type="submit"
disabled={isSubmitting || !religionType}
className="h-9 rounded-full bg-primary text-primary-foreground px-5 text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-40"
>
{isSubmitting ? "Menyimpan…" : "Simpan & Pasang Marker"}
</button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { Button } from "@/components/ui/button";
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { DashboardCard } from "@/components/dashboard-card";
import { CircleCheckIcon, ArrowRightIcon } from "@/components/ui/phosphor-icons";
interface PriorityBreakdownProps {
byLabel: Record<string, number> | null;
}
const LABELS: { key: string; label: string; dotClass: string }[] = [
{ key: "SANGAT TINGGI", label: "Sangat Tinggi", dotClass: "bg-rose-500" },
{ key: "TINGGI", label: "Tinggi", dotClass: "bg-orange-500" },
{ key: "SEDANG", label: "Sedang", dotClass: "bg-amber-500" },
{ key: "RENDAH", label: "Rendah", dotClass: "bg-sky-500" },
{ key: "TIDAK PRIORITAS", label: "Tidak Prioritas", dotClass: "bg-emerald-500" },
];
export function PriorityBreakdown({ byLabel }: PriorityBreakdownProps) {
const total = LABELS.reduce((sum, l) => sum + (byLabel?.[l.key] ?? 0), 0);
return (
<DashboardCard className="gap-0">
<CardHeader className="border-b">
<CardTitle className="text-balance text-base">
Prioritas Bantuan
</CardTitle>
<CardDescription className="text-pretty">
Distribusi skor fuzzy seluruh KK terdata.
</CardDescription>
</CardHeader>
<CardContent className="flex h-full flex-col px-0">
{total === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<CircleCheckIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Belum ada skor fuzzy.</EmptyTitle>
<EmptyDescription className="text-xs">
Tambahkan data warga atau jalankan perhitungan ulang skor di
halaman kemiskinan.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button
variant="ghost"
render={<a href="/dashboard/poverty" />}
nativeButton={false}
>
Kelola data warga
<ArrowRightIcon aria-hidden="true" />
</Button>
</EmptyContent>
</Empty>
) : (
<ul className="flex flex-col divide-y divide-border">
{LABELS.map((l) => {
const count = byLabel?.[l.key] ?? 0;
const pct = total > 0 ? (count / total) * 100 : 0;
return (
<li className="flex flex-col gap-1.5 px-6 py-3" key={l.key}>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 text-sm text-foreground">
<span
aria-hidden="true"
className={`size-2 shrink-0 rounded-full ${l.dotClass}`}
/>
{l.label}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{count} KK
</span>
</div>
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full ${l.dotClass}`}
style={{ width: `${pct}%` }}
/>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</DashboardCard>
);
}
@@ -0,0 +1,137 @@
"use client";
import { useId } from "react";
import { CartesianGrid, Line, LineChart, XAxis } from "recharts";
import { formatDate } from "@/components/formater";
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { Delta, DeltaIcon, DeltaValue } from "@/components/delta";
import { DashboardCard } from "@/components/dashboard-card";
import type { PovertyOverview } from "@/lib/poverty-types";
interface PriorityScoreChartProps {
monthly: PovertyOverview["monthly"];
}
const chartConfig = {
score: {
label: "Skor Rata-rata",
color: "var(--chart-1)",
},
count: {
label: "KK Terdata",
color: "var(--chart-2)",
},
} satisfies ChartConfig;
export function PriorityScoreChart({ monthly }: PriorityScoreChartProps) {
const chartUid = useId().replace(/:/g, "");
const idLineGlow = `priority-score-line-glow-${chartUid}`;
const chartRows = monthly.map((m) => ({
month: m.month,
score: m.avg_score != null ? Number(Number(m.avg_score).toFixed(1)) : null,
count: m.count,
}));
const scored = chartRows.filter((r) => r.score != null);
const firstScore = scored[0]?.score ?? 0;
const lastScore = scored.at(-1)?.score ?? 0;
const growthPct = firstScore > 0 ? ((lastScore - firstScore) / firstScore) * 100 : 0;
return (
<DashboardCard className="gap-0 md:col-span-2">
<CardHeader>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle>Tren Skor Prioritas</CardTitle>
{scored.length > 1 && (
<Delta value={Number(growthPct.toFixed(1))} variant="badge">
<DeltaIcon variant="trend" />
<DeltaValue />
</Delta>
)}
</div>
<CardDescription>
Skor fuzzy rata-rata dan jumlah KK terdata per bulan.
</CardDescription>
</div>
</CardHeader>
<CardContent>
{chartRows.length === 0 ? (
<div className="flex h-60 w-full items-center justify-center text-xs text-muted-foreground md:h-80">
Belum ada skor fuzzy yang dihitung.
</div>
) : (
<ChartContainer
className="aspect-auto h-60 w-full p-0 md:h-80"
config={chartConfig}
>
<LineChart
accessibilityLayer
data={chartRows}
margin={{
left: 12,
right: 12,
top: 8,
}}
>
<CartesianGrid className="stroke-border" vertical={false} />
<XAxis
axisLine={false}
dataKey="month"
interval={0}
tickFormatter={(value) => formatDate(`${value}-01`, "month")}
tickLine={false}
tickMargin={8}
/>
<ChartTooltip
content={<ChartTooltipContent hideLabel />}
cursor={false}
/>
<defs>
<filter
height="140%"
id={idLineGlow}
width="140%"
x="-20%"
y="-20%"
>
<feGaussianBlur result="blur" stdDeviation="10" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<Line
dataKey="score"
dot={false}
filter={`url(#${idLineGlow})`}
stroke="var(--color-score)"
strokeWidth={2}
type="step"
connectNulls
/>
<Line
dataKey="count"
dot={false}
filter={`url(#${idLineGlow})`}
stroke="var(--color-count)"
strokeWidth={2}
type="step"
/>
</LineChart>
</ChartContainer>
)}
</CardContent>
</DashboardCard>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { Button } from "@/components/ui/button";
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { DashboardCard } from "@/components/dashboard-card";
import { ArrowRightIcon } from "@/components/ui/phosphor-icons";
import type { HouseholdRow } from "@/lib/poverty-types";
interface RecentHouseholdsProps {
households: HouseholdRow[];
}
export function RecentHouseholds({ households }: RecentHouseholdsProps) {
return (
<DashboardCard className="relative gap-0 md:col-span-2">
<CardHeader className="border-b">
<CardTitle className="text-base">Pendataan Terbaru</CardTitle>
<CardDescription>
Warga terbaru yang masuk basis data kemiskinan.
</CardDescription>
</CardHeader>
<CardContent className="mask-b-from-50% mask-b-to-100% px-0">
{households.length === 0 ? (
<div className="flex h-48 items-center justify-center text-xs text-muted-foreground">
Belum ada data warga.
</div>
) : (
<Table>
<TableCaption className="sr-only">
Warga terbaru dengan nama kepala keluarga, prioritas, dan
penghasilan.
</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="ps-6">Kepala Keluarga</TableHead>
<TableHead>Prioritas</TableHead>
<TableHead className="pe-6 text-right tabular-nums">
Penghasilan
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{households.map((h) => (
<TableRow className="h-12" key={h.id}>
<TableCell className="max-w-40 truncate ps-6 font-medium">
{h.head_name}
</TableCell>
<TableCell className="text-muted-foreground">
{h.fuzzy_label ?? h.poverty_level}
</TableCell>
<TableCell className="pe-6 text-right tabular-nums">
Rp {h.penghasilan.toLocaleString("id-ID")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
<div className="mask-t-from-30% absolute inset-x-0 bottom-0 flex h-1/5 items-center justify-center bg-background">
<Button
className="relative"
variant="ghost"
render={<a href="/dashboard/poverty" />}
nativeButton={false}
>
Lihat Semua
<ArrowRightIcon aria-hidden="true" />
</Button>
</div>
</DashboardCard>
);
}
@@ -0,0 +1,44 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from '@/components/ui/breadcrumb'
import { HouseIcon, CaretDoubleRightIcon, GlobeHemisphereWestIcon, FileTextIcon } from '@/components/ui/phosphor-icons'
const BreadcrumbChevronsSeparatorDemo = () => {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href='#'>
<HouseIcon className='size-4' />
<span className='sr-only'>Home</span>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>
<CaretDoubleRightIcon />
</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbLink href='#' className='flex items-center gap-1'>
<GlobeHemisphereWestIcon className='size-4' />
Documents
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>
<CaretDoubleRightIcon />
</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbPage className='flex items-center gap-1'>
<FileTextIcon className='inline size-4' />
Add Document
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}
export default BreadcrumbChevronsSeparatorDemo
+89
View File
@@ -0,0 +1,89 @@
import {
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DashboardCard } from "@/components/dashboard-card";
import type { GasStationStats } from "@/lib/dashboard-types";
import type { PovertyOverview } from "@/lib/poverty-types";
interface DashboardStatsProps {
overview: PovertyOverview | null;
gasStats: GasStationStats | null;
landMarkerCount: number;
landShapeCount: number;
}
type Stat = {
label: string;
value: string;
unit?: string;
footer: string;
};
export function DashboardStats({
overview,
gasStats,
landMarkerCount,
landShapeCount,
}: DashboardStatsProps) {
const totals = overview?.totals;
const stats: Stat[] = [
{
label: "Warga Terdata",
value: totals ? totals.household_count.toLocaleString("id-ID") : "—",
unit: "KK",
footer: totals
? `${totals.total_dependents.toLocaleString("id-ID")} jiwa tanggungan`
: "belum ada data warga",
},
{
label: "Rata-rata Penghasilan",
value: totals
? `Rp ${Math.round(totals.avg_income).toLocaleString("id-ID")}`
: "—",
footer: "per kepala keluarga per bulan",
},
{
label: "Aset Lahan",
value: (landMarkerCount + landShapeCount).toLocaleString("id-ID"),
unit: "item",
footer: `${landMarkerCount} penanda · ${landShapeCount} bidang & garis`,
},
{
label: "SPBU & Fasilitas",
value: gasStats ? gasStats.total.toLocaleString("id-ID") : "—",
unit: "titik",
footer: gasStats
? `${gasStats.gas_pump} SPBU · ${gasStats.charging_station} pengisi daya EV`
: "belum ada fasilitas terpetakan",
},
];
return (
<>
{stats.map((s) => (
<DashboardCard className="" key={s.label}>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="font-normal text-xs tracking-wide">
{s.label}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-row items-baseline gap-1.5">
<p className="font-semibold text-2xl tabular-nums">{s.value}</p>
{s.unit && (
<span className="text-sm font-medium text-muted-foreground">
{s.unit}
</span>
)}
</CardContent>
<CardFooter className="gap-1 rounded-none bg-background text-xs">
<span className="text-muted-foreground">{s.footer}</span>
</CardFooter>
</DashboardCard>
))}
</>
);
}
+197
View File
@@ -0,0 +1,197 @@
'use client';
import { cn } from '@/lib/utils';
import { Undo2 } from 'lucide-react';
import { useEffect, useState, type FC, type ReactNode } from 'react';
import { AnimatePresence, motion, MotionConfig } from 'motion/react';
import useMeasure from 'react-use-measure';
export interface TimedUndoActionProps {
initialSeconds?: number;
deleteLabel?: string;
undoLabel?: string;
icon?: ReactNode;
onConfirm?: () => void;
compact?: boolean;
/** Controlled mode: external isDeleting state */
isDeleting?: boolean;
/** Controlled mode: external countdown value */
countDown?: number;
/** Controlled mode: called when user clicks to toggle */
onToggle?: () => void;
}
export const TimedUndoAction: FC<TimedUndoActionProps> = ({
initialSeconds = 10,
deleteLabel = 'Delete Account',
undoLabel = 'Cancel Delete',
icon,
onConfirm,
compact = false,
isDeleting: controlledIsDeleting,
countDown: controlledCountDown,
onToggle,
}) => {
const isControlled = controlledIsDeleting !== undefined;
const [uncontrolledIsDeleting, setUncontrolledIsDeleting] = useState(false);
const [uncontrolledCountDown, setUncontrolledCountDown] = useState(initialSeconds);
const [ref, bounds] = useMeasure({ offsetSize: true });
const isDeleting = isControlled ? controlledIsDeleting! : uncontrolledIsDeleting;
const countDown = isControlled ? (controlledCountDown ?? initialSeconds) : uncontrolledCountDown;
const handleClick = () => {
if (isControlled) {
onToggle?.();
} else {
setUncontrolledIsDeleting((prev) => {
const next = !prev;
if (next) setUncontrolledCountDown(initialSeconds);
return next;
});
}
};
// Uncontrolled: tick the countdown
useEffect(() => {
if (isControlled || !uncontrolledIsDeleting) return;
const id = setInterval(() => {
setUncontrolledCountDown((p) => Math.max(0, p - 1));
}, 1000);
return () => clearInterval(id);
}, [isControlled, uncontrolledIsDeleting]);
// Uncontrolled: fire onConfirm when countdown hits 0
useEffect(() => {
if (isControlled || !uncontrolledIsDeleting || uncontrolledCountDown > 0) return;
setUncontrolledIsDeleting(false);
setUncontrolledCountDown(initialSeconds);
onConfirm?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isControlled, uncontrolledIsDeleting, uncontrolledCountDown]);
return (
<div className="flex items-center font-sans">
<div className="flex flex-col items-center justify-center will-change-transform">
<MotionConfig
transition={{
type: 'spring',
stiffness: 250,
damping: 22,
}}
>
<motion.div
className={cn(
'relative flex cursor-pointer items-center justify-start overflow-hidden rounded-full border border-red-500 bg-transparent transition-colors duration-300 hover:bg-red-500/5 dark:border-red-400 dark:hover:bg-red-400/5',
compact && 'w-36 h-9',
isDeleting && 'bg-red-500/10 border-red-500/20 hover:bg-red-500/10 dark:bg-red-500/20 dark:border-red-500/10 dark:hover:bg-red-500/20',
)}
animate={compact ? undefined : {
width: bounds.width > 0 ? bounds.width : 'auto',
}}
onClick={handleClick}
>
<div
className={cn(
'flex items-center gap-2',
compact ? 'w-full h-full px-1.5' : 'px-6 py-3',
compact ? (isDeleting ? 'justify-between' : 'justify-center') : 'justify-center',
)}
ref={ref}
>
<AnimatePresence mode="popLayout">
{isDeleting && (
<motion.div
className={cn(
"rounded-full bg-red-500 flex items-center justify-center shrink-0",
compact ? "w-6 h-6" : "p-2"
)}
initial={{ opacity: 0, filter: 'blur(2px)' }}
animate={{ opacity: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, filter: 'blur(2px)' }}
>
{icon ?? <Undo2 className={cn(compact ? "size-3.5" : "size-5", "text-white")} />}
</motion.div>
)}
</AnimatePresence>
<div className="flex items-center justify-center gap-2">
<AnimatedText
text={isDeleting ? undoLabel : deleteLabel}
className={cn(
'z-10 font-medium',
compact ? 'text-sm' : 'text-lg',
'text-red-500 dark:text-red-400',
)}
/>
</div>
<AnimatePresence mode="popLayout">
{isDeleting && (
<motion.div
className={cn(
"flex items-center justify-center rounded-full bg-red-500 text-neutral-50 tabular-nums shrink-0",
compact ? "w-6 h-6 text-xs" : "px-3 py-1 text-neutral-50"
)}
initial={{ opacity: 0, filter: 'blur(2px)' }}
animate={{ opacity: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, filter: 'blur(2px)' }}
>
<AnimatePresence mode="popLayout">
<motion.span
key={countDown}
className={compact ? 'text-[11px]' : 'text-lg'}
initial={{ opacity: 0, y: -20, filter: 'blur(2px)', scale: 0.5 }}
animate={{ opacity: 1, y: 0, filter: 'blur(0px)', scale: 1 }}
exit={{ opacity: 0, y: 20, filter: 'blur(2px)', scale: 0.5 }}
transition={{ type: 'spring', stiffness: 240, damping: 20, mass: 1 }}
>
{countDown}
</motion.span>
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
</MotionConfig>
</div>
</div>
);
};
function AnimatedText({
text,
className,
delayStep = 0.014,
}: {
text: string;
className?: string;
delayStep?: number;
}) {
const chars = text.split('');
return (
<span className={className} style={{ display: 'inline-flex' }}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={text} style={{ display: 'inline-flex ', willChange: 'transform' }}>
{chars.map((char, i) => (
<motion.span
key={i}
initial={{ y: 10, opacity: 0, scale: 0.5, filter: 'blur(2px)' }}
animate={{ y: 0, opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ y: -10, opacity: 0, scale: 0.5, filter: 'blur(2px)' }}
transition={{ type: 'spring', stiffness: 240, damping: 16, mass: 1.2, delay: i * delayStep }}
style={{ display: 'inline-block', whiteSpace: char === ' ' ? 'pre' : undefined }}
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</span>
);
}
export default TimedUndoAction;
+192
View File
@@ -0,0 +1,192 @@
"use client";
import { useState, useMemo, type FC, type ChangeEvent } from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";
interface AdaptiveSliderProps {
value?: number;
min?: number;
max?: number;
step?: number;
defaultValue?: number;
onChange?: (value: number) => void;
}
interface ColorSettings {
text: string;
gradient: string;
thumbBorder: string;
}
const DEFAULT_MIN = 50;
const DEFAULT_MAX = 350;
const DEFAULT_STEP = 25;
const DEFAULT_VALUE = 200;
const getColorSettings = (
value: number,
min: number,
max: number
): ColorSettings => {
const percentage = (value - min) / (max - min);
if (percentage < 0.5) {
return {
text: "#10B981",
gradient: "linear-gradient(to right, #FEB101, #FE7C09)",
thumbBorder: "#10B981",
};
} else if (percentage < 0.7) {
return {
text: "#FE55B7",
gradient: "linear-gradient(to right, #FE55B74D, #FE55B7)",
thumbBorder: "#F97316",
};
} else {
return {
text: "#D946EF",
gradient: "linear-gradient(to right, #DAB0FE, #4946FF)",
thumbBorder: "#D946EF",
};
}
};
export const AdaptiveSlider: FC<AdaptiveSliderProps> = ({
value,
min = DEFAULT_MIN,
max = DEFAULT_MAX,
step = DEFAULT_STEP,
defaultValue = DEFAULT_VALUE,
onChange,
}) => {
const [internalValue, setInternalValue] = useState<number>(defaultValue);
const calories = value ?? internalValue;
const colorSettings = useMemo(
() => getColorSettings(calories, min, max),
[calories, min, max]
);
const percentage = ((calories - min) / (max - min)) * 100;
const dots = useMemo(
() =>
Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="z-30 h-1.5 w-1.5 rounded-full bg-[#C4B9FA] transition-colors dark:bg-neutral-600"
style={{ opacity: 0.8 }}
/>
)),
[]
);
const handleSliderChange = (e: ChangeEvent<HTMLInputElement>) => {
const val = Number(e.target.value);
setInternalValue(val);
onChange?.(val);
};
return (
<motion.div className="flex h-[60vh] w-xs flex-col items-center justify-center rounded-[36px] bg-[#FEFEFE] p-6 shadow-2xl shadow-black/5 transition-colors select-none sm:w-sm sm:p-12 dark:bg-neutral-900 dark:shadow-none">
<span className="mb-2 text-xl font-bold text-[#878787] sm:text-2xl dark:text-neutral-500">
Calories
</span>
<div className="mb-8 flex items-baseline gap-2">
<AnimatedText
value={calories.toString()}
className="overflow-hidden text-5xl font-extrabold tracking-tight sm:text-6xl"
/>
<motion.span
layout
className="text-4xl font-extrabold text-[#010101] transition-colors sm:text-5xl dark:text-neutral-100"
>
kCal
</motion.span>
</div>
<div className="group relative flex h-13 w-full items-center overflow-hidden rounded-full bg-[#f1f3f5] transition-colors dark:bg-neutral-800">
<div className="pointer-events-none absolute inset-0 flex items-center justify-between px-4 transition-colors sm:px-8">
{dots}
</div>
<motion.div
className="pointer-events-none absolute top-0 left-0 h-full rounded-full"
animate={{
width: `calc((${percentage} / 100) * (100% - 52px) + 52px)`,
background: colorSettings.gradient,
}}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
<input
title="range"
type="range"
min={min}
max={max}
step={step}
value={calories}
onChange={handleSliderChange}
className="absolute inset-0 z-50 h-13 w-full cursor-pointer opacity-0"
/>
<motion.div
className="pointer-events-none absolute top-0 z-40 flex size-13 items-center justify-center rounded-full border-none"
animate={{
left: `calc((${percentage} / 100) * (100% - 52px))`,
}}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<div className="size-10 rounded-full bg-white shadow-[inset_0_2px_4px_rgba(0,0,0,0.06)]" />
</motion.div>
</div>
</motion.div>
);
};
const AnimatedText = ({
value,
className,
}: {
value: string;
className?: string;
}) => {
return (
<div
className={cn(
"flex text-lg tracking-tight will-change-transform",
className
)}
>
<AnimatePresence mode="popLayout" initial={false}>
{value.split("").map((char, index) => {
const displayChar = char === " " ? "\u00A0" : char;
return (
<motion.span
key={char + index}
initial={{ opacity: 1, y: 0, scale: 1 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
transition: {
type: "spring",
stiffness: 200,
damping: 20,
// delay: 0.03 * index,
},
}}
exit={{ opacity: 0, y: 0, scale: 1, transition: { duration: 0 } }}
>
{displayChar}
</motion.span>
);
})}
</AnimatePresence>
</div>
);
};
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+125
View File
@@ -0,0 +1,125 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "@/components/ui/phosphor-icons"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props
),
render,
state: {
slot: "breadcrumb-link",
},
})
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-white py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+383
View File
@@ -0,0 +1,383 @@
"use client"
/* eslint-disable react-hooks/set-state-in-effect */
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
initialDimension?: {
width: number
height: number
}
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
const [isMounted, setIsMounted] = React.useState(false)
React.useEffect(() => {
setIsMounted(true)
}, [])
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
{isMounted ? (
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
) : (
<div className="w-full h-full" />
)}
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+21
View File
@@ -0,0 +1,21 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
@@ -0,0 +1,540 @@
"use client";
import { useState, type FC } from "react";
import {
motion,
AnimatePresence,
MotionConfig,
type Transition,
} from "framer-motion";
import useMeasure from "react-use-measure";
import { cn } from "@/lib/utils";
import { useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
// 1. PhosphorIcon mask component
export interface PhosphorIconProps {
name: string;
className?: string;
}
export const PhosphorIcon = ({ name, className }: PhosphorIconProps) => {
const fileName = name.endsWith("-light") ? name : `${name}-light`;
return (
<span
className={cn("inline-block bg-current shrink-0", className)}
style={{
maskImage: `url(/light/${fileName}.svg)`,
WebkitMaskImage: `url(/light/${fileName}.svg)`,
maskSize: "contain",
WebkitMaskSize: "contain",
maskRepeat: "no-repeat",
WebkitMaskRepeat: "no-repeat",
maskPosition: "center",
WebkitMaskPosition: "center",
width: "100%",
height: "100%",
}}
/>
);
};
export interface CollectionItem {
id: string;
name: string;
price?: number;
unit?: string; // e.g. "jiwa", "KK" — shown after the value instead of a "$" prefix
icon: string; // Phosphor icon name
isActive?: boolean;
}
export interface Collection {
id: string;
name: string;
items: CollectionItem[];
}
interface DisclosureCardProps {
collections: Collection[];
compact?: boolean;
onItemClick?: (itemId: string, collectionId: string) => void;
dragDropEnabled?: boolean;
}
const springConfig: Transition = {
type: "spring",
stiffness: 200,
damping: 20,
mass: 1.1,
};
export const DisclosureCard: FC<DisclosureCardProps> = ({
collections,
compact = false,
onItemClick,
dragDropEnabled = false,
}) => {
return (
<div className={cn("flex w-full items-center justify-center", compact && "w-auto")}>
<motion.div
className="flex flex-col gap-2 will-change-transform"
layout="position"
transition={springConfig}
>
{collections.map((collection) => (
<GridContainer
key={collection.id}
title={collection.name}
items={collection.items}
compact={compact}
collectionId={collection.id}
onItemClick={onItemClick}
dragDropEnabled={dragDropEnabled}
/>
))}
</motion.div>
</div>
);
};
// Draggable wrapper for each expanded item
const DraggableItem = ({
item,
collectionId,
compact,
onItemClick,
dragDropEnabled = false,
}: {
item: CollectionItem;
collectionId: string;
compact: boolean;
onItemClick?: (itemId: string, collectionId: string) => void;
dragDropEnabled?: boolean;
}) => {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `${collectionId}::${item.id}`,
data: { itemId: item.id, collectionId },
disabled: !dragDropEnabled || collectionId === "maps-settings" || collectionId === "demographics",
});
const style = transform
? { transform: CSS.Translate.toString(transform), zIndex: isDragging ? 9999 : undefined, opacity: isDragging ? 0.8 : undefined }
: undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={cn(
"relative flex items-center justify-center gap-2 rounded-md p-1 transition-colors touch-none",
onItemClick ? "cursor-grab active:cursor-grabbing hover:bg-zinc-200/30 dark:hover:bg-zinc-700/30" : ""
)}
onClick={() => {
if (isDragging) return;
if (onItemClick) onItemClick(item.id, collectionId);
}}
>
{item.isActive && (
<motion.div
layoutId={`active-highlight-${collectionId}`}
className="absolute inset-0 bg-transparent rounded-md border border-primary/30 pointer-events-none"
initial={false}
transition={springConfig}
/>
)}
<motion.div
className={cn(
"flex items-center justify-center rounded-full z-10 transition-colors",
compact ? "size-7" : "size-10",
item.isActive ? "bg-transparent text-primary" : "bg-transparent text-zinc-700 dark:text-zinc-300"
)}
layoutId={`${item.name}`}
>
<PhosphorIcon name={item.icon} className={cn("text-current", compact ? "size-3.5" : "size-5")} />
</motion.div>
<motion.div
className="flex flex-1 flex-col items-start justify-center gap-0.5 leading-none z-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<motion.p className={cn("text-zinc-800 dark:text-zinc-100 font-medium", compact ? "text-[11px]" : "text-md")}>
{item.name}
</motion.p>
{item.price !== undefined && item.price > 0 && (
<div className="flex gap-1 text-zinc-400 dark:text-zinc-500">
<p className={compact ? "text-[9px]" : "text-sm"}>
{item.unit ? `${item.price.toLocaleString("id-ID")} ${item.unit}` : `$${item.price}`}
</p>
</div>
)}
</motion.div>
<div className="z-10 flex items-center justify-center">
{item.isActive ? (
<PhosphorIcon name="check" className={cn("text-primary font-bold", compact ? "size-3.5" : "size-5")} />
) : (
<PhosphorIcon name="caret-right" className={cn("text-zinc-400 dark:text-zinc-500", compact ? "size-3.5" : "size-5")} />
)}
</div>
</div>
);
};
const GridContainer = ({
items,
title,
compact = false,
collectionId,
onItemClick,
dragDropEnabled = false,
}: {
items: CollectionItem[];
title: string;
compact?: boolean;
collectionId: string;
onItemClick?: (itemId: string, collectionId: string) => void;
dragDropEnabled?: boolean;
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [ref, bounds] = useMeasure({ offsetSize: true });
const cardWidth = compact ? "w-[220px]" : "w-[300px]";
const cardBg = compact
? "border-border/60 bg-background/95 backdrop-blur-md shadow-md"
: "border-gray-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800";
const titleSize = compact ? "text-xs font-semibold text-foreground" : "text-lg text-zinc-900 dark:text-zinc-100";
const subtitleSize = compact ? "text-[10px] text-muted-foreground" : "text-sm text-zinc-500 dark:text-zinc-400";
const circleSize = compact ? "size-5" : "size-6";
const circleIconSize = compact ? "size-3" : "size-4";
const chevronSize = compact ? "size-3.5" : "size-6";
return (
<MotionConfig transition={springConfig}>
<motion.div
className={cn("cursor-pointer overflow-hidden rounded-lg border", cardWidth, cardBg)}
animate={{
height: bounds.height > 0 ? bounds.height : "auto",
}}
>
<div className="p-2" ref={ref}>
<AnimatePresence
mode="popLayout"
key={isExpanded ? "expanded" : "collapsed"}
propagate
>
{!isExpanded ? (
<motion.div
key={"collapsed"}
className="flex w-full items-center space-x-2"
onClick={() => setIsExpanded(true)}
exit={{ opacity: 0 }}
transition={{ duration: 0.1, ease: "easeOut" }}
>
<div className="grid grid-cols-2 gap-1">
{items.slice(0, 4).map((item, index) => (
<motion.div
className={cn(
"relative flex items-center justify-center rounded-full bg-zinc-300 p-1 dark:bg-zinc-700",
circleSize
)}
key={`${item.name}-${index}`}
layoutId={`${item.name}`}
transition={{ ...springConfig, delay: 0.01 }}
>
<PhosphorIcon name={item.icon} className={cn("text-zinc-700 dark:text-zinc-200", circleIconSize)} />
</motion.div>
))}
</div>
<div className="ml-2 flex flex-1 flex-col items-start justify-center">
<motion.span
layoutId={`title-${title}`}
layout="position"
className={titleSize}
>
{title}
</motion.span>
<span className={subtitleSize}>
{items.length} Item
</span>
</div>
<div className="flex items-center justify-center">
<PhosphorIcon name="caret-right" className={cn("text-zinc-500 dark:text-zinc-400", chevronSize)} />
</div>
</motion.div>
) : (
<motion.div
key={"expanded"}
className="flex w-full flex-col gap-3"
exit={{ opacity: 0 }}
transition={{ duration: 0.01, ease: "easeOut" }}
>
<motion.div className="flex items-center px-1" layout>
<motion.span
className={cn("flex-1", titleSize)}
layoutId={`title-${title}`}
layout="position"
>
{title}
</motion.span>
<div
className="flex items-center justify-center rounded-full bg-zinc-300 dark:bg-zinc-700 p-1 hover:bg-zinc-400 dark:hover:bg-zinc-600 transition-colors"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(false);
}}
>
<PhosphorIcon name="x" className={cn("text-zinc-700 dark:text-zinc-200", compact ? "size-3" : "size-4")} />
</div>
</motion.div>
<div className={cn("flex flex-col", compact ? "gap-1.5" : "gap-3")}>
<AnimatePresence mode="popLayout">
{items.map((item) => (
<DraggableItem
key={item.id}
item={item}
collectionId={collectionId}
compact={compact}
onItemClick={onItemClick}
dragDropEnabled={dragDropEnabled}
/>
))}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
</MotionConfig>
);
};
// 2. Specialized Poverty Disclosure Card
interface DashboardDisclosureCardProps {
mapStyle: "street" | "satellite";
onMapStyleChange: (style: "street" | "satellite") => void;
onToolSelect: (toolId: string | null) => void;
activeTool: string | null;
compact?: boolean;
dragDropEnabled: boolean;
onDragDropToggle: (enabled: boolean) => void;
}
export interface PovertyDemographics {
population: number; // total jiwa across all registered households
beneficiaries: number; // households (KK) classified Extreme/Miskin
socialSecurity: number; // households (KK) classified Rentan
}
interface PovertyDisclosureCardProps extends DashboardDisclosureCardProps {
demographics?: PovertyDemographics;
}
export const PovertyDisclosureCard: FC<PovertyDisclosureCardProps> = ({
mapStyle,
onMapStyleChange,
onToolSelect,
activeTool,
compact = true,
demographics,
dragDropEnabled,
onDragDropToggle,
}) => {
const collections: Collection[] = [
{
id: "maps-settings",
name: "Pengaturan Peta",
items: [
{ id: "street", name: "Tampilan Jalan", icon: "map-trifold", isActive: mapStyle === "street" },
{ id: "satellite", name: "Tampilan Satelit", icon: "globe", isActive: mapStyle === "satellite" },
{ id: "drag-drop", name: "Seret & Lepas", icon: "hand-grabbing", isActive: dragDropEnabled },
],
},
{
id: "poverty-tools",
name: "Alat Peta Kemiskinan",
items: [
{ id: "marker", name: "Tambah Data Warga", icon: "map-pin", isActive: activeTool === "marker" },
{ id: "religion", name: "Tambah Rumah Ibadah", icon: "church", isActive: activeTool === "religion" },
{ id: "clinic", name: "Tambah Klinik", icon: "first-aid", isActive: activeTool === "clinic" },
{ id: "food-bank", name: "Tambah Lumbung Pangan", icon: "bowl-food", isActive: activeTool === "food-bank" },
{ id: "school", name: "Tambah Sekolah", icon: "graduation-cap", isActive: activeTool === "school" },
],
},
{
id: "demographics",
name: "Demografi",
items: [
{ id: "dm-1", name: "Total Warga Terdata", price: demographics?.population ?? 0, unit: "jiwa", icon: "users" },
{ id: "dm-2", name: "KK Miskin & Ekstrem", price: demographics?.beneficiaries ?? 0, unit: "KK", icon: "hand-heart" },
{ id: "dm-3", name: "KK Rentan", price: demographics?.socialSecurity ?? 0, unit: "KK", icon: "shield-check" },
],
},
];
return (
<DisclosureCard
collections={collections}
compact={compact}
onItemClick={(itemId, collectionId) => {
if (collectionId === "maps-settings") {
if (itemId === "drag-drop") {
onDragDropToggle(!dragDropEnabled);
} else {
onMapStyleChange(itemId as "street" | "satellite");
}
} else if (collectionId === "poverty-tools") {
onToolSelect(activeTool === itemId ? null : itemId);
}
}}
dragDropEnabled={dragDropEnabled}
/>
);
};
// 3. Specialized Land Disclosure Card
export const LandDisclosureCard: FC<DashboardDisclosureCardProps> = ({
mapStyle,
onMapStyleChange,
onToolSelect,
activeTool,
compact = true,
dragDropEnabled,
onDragDropToggle,
}) => {
const collections: Collection[] = [
{
id: "maps-settings",
name: "Pengaturan Peta",
items: [
{ id: "street", name: "Tampilan Jalan", icon: "map-trifold", isActive: mapStyle === "street" },
{ id: "satellite", name: "Tampilan Satelit", icon: "globe", isActive: mapStyle === "satellite" },
{ id: "drag-drop", name: "Seret & Lepas", icon: "hand-grabbing", isActive: dragDropEnabled },
],
},
{
id: "land-tools",
name: "Alat Peta Lahan",
items: [
{ id: "marker", name: "Tambah Penanda", icon: "map-pin", isActive: activeTool === "marker" },
{ id: "flag", name: "Tambah Tengara", icon: "flag", isActive: activeTool === "flag" },
{ id: "protected", name: "Tambah Kawasan Lindung", icon: "tree", isActive: activeTool === "protected" },
{ id: "registry", name: "Tambah Kantor Pendaftaran", icon: "bank", isActive: activeTool === "registry" },
{ id: "line", name: "Gambar Garis", icon: "bezier-curve", isActive: activeTool === "line" },
{ id: "polygon", name: "Gambar Poligon", icon: "hexagon", isActive: activeTool === "polygon" },
{ id: "circle", name: "Gambar Lingkaran", icon: "circle", isActive: activeTool === "circle" },
],
},
{
id: "land-permits",
name: "Izin Lahan",
items: [
{ id: "lp-1", name: "Izin Mendirikan Bangunan (IMB)", price: 342, icon: "building" },
{ id: "lp-2", name: "Sertifikat Tanah", price: 1580, icon: "certificate" },
{ id: "lp-3", name: "Banding Zonasi", price: 47, icon: "gavel" },
],
},
{
id: "environmental",
name: "Lingkungan",
items: [
{ id: "ev-1", name: "Tutupan Hijau", price: 67.8, icon: "leaf" },
{ id: "ev-2", name: "Aliran Air", price: 23.4, icon: "drop" },
{ id: "ev-3", name: "Jaringan Jalan", price: 1420, icon: "road-horizon" },
],
},
];
return (
<DisclosureCard
collections={collections}
compact={compact}
onItemClick={(itemId, collectionId) => {
if (collectionId === "maps-settings") {
if (itemId === "drag-drop") {
onDragDropToggle(!dragDropEnabled);
} else {
onMapStyleChange(itemId as "street" | "satellite");
}
} else if (collectionId === "land-tools") {
onToolSelect(activeTool === itemId ? null : itemId);
}
}}
dragDropEnabled={dragDropEnabled}
/>
);
};
// 4. Specialized Gas Station Disclosure Card
export const GasStationDisclosureCard: FC<DashboardDisclosureCardProps> = ({
mapStyle,
onMapStyleChange,
onToolSelect,
activeTool,
compact = true,
dragDropEnabled,
onDragDropToggle,
}) => {
const collections: Collection[] = [
{
id: "maps-settings",
name: "Pengaturan Peta",
items: [
{ id: "street", name: "Tampilan Jalan", icon: "map-trifold", isActive: mapStyle === "street" },
{ id: "satellite", name: "Tampilan Satelit", icon: "globe", isActive: mapStyle === "satellite" },
{ id: "drag-drop", name: "Seret & Lepas", icon: "hand-grabbing", isActive: dragDropEnabled },
],
},
{
id: "station-tools",
name: "Alat Peta Stasiun",
items: [
{ id: "charging-station", name: "Tambah Pengisi Daya EV", icon: "charging-station", isActive: activeTool === "charging-station" },
{ id: "gas-pump", name: "Tambah SPBU", icon: "gas-pump", isActive: activeTool === "gas-pump" },
{ id: "wrench", name: "Tambah Bengkel", icon: "wrench", isActive: activeTool === "wrench" },
],
},
{
id: "station-services",
name: "Layanan Stasiun",
items: [
{ id: "ss-1", name: "Toko Kelontong", price: 450, icon: "shopping-bag" },
{ id: "ss-2", name: "Pencucian Mobil", price: 75, icon: "bathtub" },
{ id: "ss-3", name: "Mekanik", price: 320, icon: "wrench" },
{ id: "ss-4", name: "Tukar Baterai", price: 180, icon: "car-battery" },
],
},
{
id: "fleet-logistics",
name: "Armada & Logistik",
items: [
{ id: "fl-1", name: "Kartu Armada", price: 1240, icon: "credit-card" },
{ id: "fl-2", name: "Rencana Rute", price: 89, icon: "paper-plane" },
{ id: "fl-3", name: "Laporan Bahan Bakar", price: 56, icon: "file-text" },
],
},
];
return (
<DisclosureCard
collections={collections}
compact={compact}
onItemClick={(itemId, collectionId) => {
if (collectionId === "maps-settings") {
if (itemId === "drag-drop") {
onDragDropToggle(!dragDropEnabled);
} else {
onMapStyleChange(itemId as "street" | "satellite");
}
} else if (collectionId === "station-tools") {
onToolSelect(activeTool === itemId ? null : itemId);
}
}}
dragDropEnabled={dragDropEnabled}
/>
);
};
+119
View File
@@ -0,0 +1,119 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect, type FC, useId } from "react";
import { motion, LayoutGroup } from "framer-motion";
/* ---------- Types ---------- */
interface TabItem {
id: string;
label: string;
}
interface ContinuousTabsProps {
tabs?: TabItem[];
activeId?: string;
defaultActiveId?: string;
onChange?: (id: string) => void;
size?: "sm" | "md";
layoutId?: string;
}
/* ---------- Defaults ---------- */
const DEFAULT_TABS: TabItem[] = [
{ id: "home", label: "Home" },
{ id: "interactions", label: "Interactions" },
{ id: "resources", label: "Resources" },
{ id: "docs", label: "Docs" },
];
export const ContinuousTabs: FC<ContinuousTabsProps> = ({
tabs = DEFAULT_TABS,
activeId,
defaultActiveId = "home",
onChange,
size = "md",
layoutId,
}) => {
const [activeState, setActiveState] = useState<string>(defaultActiveId);
const [isMounted, setIsMounted] = useState<boolean>(false);
const uniqueId = useId();
useEffect(() => {
setIsMounted(true);
}, []);
const active = activeId !== undefined ? activeId : activeState;
const finalLayoutId = layoutId ?? `active-pill-${uniqueId}`;
const handleChange = (id: string) => {
setActiveState(id);
onChange?.(id);
};
if (!isMounted) return null;
const isSm = size === "sm";
return (
<LayoutGroup>
<nav
className={`
relative flex items-center bg-muted/40 dark:bg-charcoal/50
border border-border
rounded-full transition-all duration-300
shadow-[inset_0_-1px_2px_rgba(0,0,0,0.05)]
${isSm ? "gap-0.5 p-1" : "gap-0.5 sm:gap-1 p-1.5 sm:p-2"}
`}
>
{tabs.map((tab) => {
const isActive = active === tab.id;
return (
<button
key={tab.id}
onClick={() => handleChange(tab.id)}
className={`
relative flex items-center justify-center rounded-full outline-none transition-transform active:scale-[0.98]
${isSm ? "px-3 py-1.5" : "px-5 py-2 sm:px-6 sm:py-2.5"}
`}
>
{/* Active pill */}
{isActive && (
<motion.div
layoutId={finalLayoutId}
transition={{
type: "spring",
stiffness: 380,
damping: 30,
mass: 0.9,
}}
className="
absolute inset-0 rounded-full
bg-[#ff3b1f]
shadow-sm
"
/>
)}
{/* Text */}
<motion.span
layout="position"
className={`
relative z-10 font-semibold leading-none transition-colors duration-200
${isSm ? "text-[11px] sm:text-xs" : "text-sm sm:text-base"}
${isActive
? "text-white"
: "text-muted-foreground hover:text-foreground"
}
`}
>
{tab.label}
</motion.span>
</button>
);
})}
</nav>
</LayoutGroup>
);
};
+113
View File
@@ -0,0 +1,113 @@
"use client"
import type { ComponentPropsWithoutRef } from "react"
import { cn } from "@/lib/utils"
/**
* Requires these keyframes and utilities in your CSS (e.g. global CSS or Tailwind):
*
* @keyframes cosmic-spin {
* from { transform: rotate(0deg); }
* to { transform: rotate(360deg); }
* }
* @keyframes cosmic-spin-slow {
* from { transform: rotate(0deg); }
* to { transform: rotate(-360deg); }
* }
* @utility animate-cosmic-spin {
* animation: cosmic-spin 3s linear infinite;
* }
* @utility animate-cosmic-spin-slow {
* animation: cosmic-spin-slow 5s linear infinite;
* }
*/
export type CosmicButtonProps<E extends "a" | "button" = "a"> = {
/** The HTML element to render as. @default "a" */
as?: E
size?: "sm" | "md"
} & ComponentPropsWithoutRef<E>
/**
* An animated button/link with a cosmic gradient border effect.
* Renders as an anchor by default; use `as="button"` for button behavior.
*
* @example
* // As link (default)
* <CosmicButton href="/about">About</CosmicButton>
*
* @example
* // As button
* <CosmicButton as="button" onClick={handleClick}>Submit</CosmicButton>
*/
export function CosmicButton<E extends "a" | "button" = "a">({
as,
size = "md",
className,
children,
...props
}: CosmicButtonProps<E>) {
const Element = as ?? "a"
const isAnchor = Element === "a"
const isSm = size === "sm"
const baseClassName = cn(
"group/cosmic relative inline-flex items-center justify-center rounded-full p-[2px] transition-transform ",
isSm ? "min-h-8 min-w-8" : "min-h-11 min-w-11",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ff3b1f] focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className
)
const content = (
<>
{/* Animated cosmic border - enlarges on hover */}
<span className="absolute inset-0 overflow-hidden rounded-full transition-all duration-300 ease-out group-hover/cosmic:inset-[-2px] group-hover/cosmic:rounded-full">
<span className="absolute inset-[-200%] animate-cosmic-spin bg-[conic-gradient(from_0deg,#ff3b1f,#5683d2,#ffffff,#5683d2,#ff3b1f)] opacity-95" />
</span>
{/* Noise/texture overlay on the border - enlarges on hover */}
<span className="absolute inset-0 overflow-hidden rounded-full opacity-45 mix-blend-soft-light transition-all duration-300 ease-out group-hover/cosmic:inset-[-2px] group-hover/cosmic:rounded-full dark:opacity-60 dark:mix-blend-overlay">
<span className="absolute inset-[-200%] animate-cosmic-spin-slow bg-[conic-gradient(from_180deg,#ffffff_0%,transparent_30%,#ff3b1f_50%,transparent_70%,#5683d2_100%)]" />
</span>
{/* Theme-aware inner background */}
<span className={cn(
"relative z-10 flex items-center rounded-full bg-muted shadow-[inset_0_1px_0_rgba(255,255,255,0.72),inset_0_-1px_0_rgba(15,23,42,0.08),0_1px_1px_rgba(15,23,42,0.08),0_8px_24px_rgba(15,23,42,0.14)] transition-all duration-300 group-hover/cosmic:shadow-[inset_0_1px_0_rgba(255,255,255,0.82),inset_0_-1px_0_rgba(15,23,42,0.12),0_2px_6px_rgba(15,23,42,0.14),0_12px_34px_rgba(15,23,42,0.2)] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(0,0,0,0.5),0_1px_1px_rgba(0,0,0,0.45),0_10px_28px_rgba(0,0,0,0.35)] dark:group-hover/cosmic:shadow-[inset_0_1px_0_rgba(255,255,255,0.16),inset_0_-1px_0_rgba(0,0,0,0.6),0_2px_6px_rgba(0,0,0,0.55),0_14px_34px_rgba(0,0,0,0.42)] active:scale-[0.98]",
isSm ? "px-3.5 py-1.5 gap-1.5" : "px-5 py-2.5 gap-3"
)}>
<span className={cn(
"font-medium tracking-wide text-foreground",
isSm ? "text-xs" : "text-base"
)}>
{children ?? "Placeholder text"}
</span>
</span>
</>
)
if (isAnchor) {
const { href, rel, target, ...rest } =
props as ComponentPropsWithoutRef<"a">
return (
<a
className={baseClassName}
href={href ?? "https://aisdkagents.com"}
rel={rel ?? "noopener noreferrer"}
target={target ?? "_blank"}
{...rest}
>
{content}
</a>
)
}
return (
<button
className={baseClassName}
{...(props as ComponentPropsWithoutRef<"button">)}
>
{content}
</button>
)
}
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+268
View File
@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "@/components/ui/phosphor-icons"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useState, useEffect, type FC, type ReactNode } from "react";
import { motion, AnimatePresence, MotionConfig } from "motion/react";
import { ChevronUpIcon } from "lucide-react";
export interface ActivityItemType {
icon: ReactNode;
title: string;
desc: string;
time: string;
}
export interface ActivitiesCardProps {
headerIcon: ReactNode;
title: string;
subtitle: string;
activities: ActivityItemType[];
}
const ActivityItem: FC<ActivityItemType> = ({ icon, title, desc, time }) => {
return (
<motion.div
layout
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="flex cursor-pointer items-center gap-3 px-3 py-3 transition-colors hover:bg-neutral-50 sm:gap-4 sm:px-5 dark:hover:bg-neutral-800/50"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-gray-100/50 bg-gradient-to-b from-[#f4f4f7]/90 to-[#E9EAF0]/90 text-gray-400 sm:h-12 sm:w-12 dark:border-neutral-700 dark:from-neutral-800 dark:to-neutral-900 dark:text-neutral-500">
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[15px] leading-tight font-bold text-[#3E3E43] sm:text-[17px] dark:text-neutral-200">
{title}
</p>
<p className="truncate text-[13px] text-[#909092] sm:text-[15px] dark:text-neutral-500">
{desc}
</p>
</div>
<span className="pt-1 text-[11px] whitespace-nowrap text-[#9F9FA1] sm:text-[13px] dark:text-neutral-600">
{time}
</span>
</motion.div>
);
};
export const ActivitiesCard: FC<ActivitiesCardProps> = ({
headerIcon,
title,
subtitle,
activities,
}) => {
const [open, setOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 640);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
return (
<MotionConfig transition={{ type: "spring", bounce: 0, duration: 0.6 }}>
<motion.div
layout
className="w-xs overflow-hidden rounded-xl border-2 border-[#e7e6e6]/60 bg-[#FEFEFE] shadow-lg sm:w-sm sm:rounded-[20px] dark:border-neutral-800 dark:bg-neutral-900"
>
<motion.button
onClick={() => setOpen(!open)}
className="flex w-full items-center justify-between gap-2 px-3 py-2 transition-colors sm:gap-3 sm:px-4 sm:py-3.5"
>
<div className="flex min-w-0 flex-1 items-center gap-3 text-left sm:gap-4">
<motion.div
initial={{
width: isMobile ? 48 : 60,
height: isMobile ? 48 : 60,
}}
animate={{
width: open ? (isMobile ? 36 : 48) : isMobile ? 48 : 60,
height: open ? (isMobile ? 36 : 48) : isMobile ? 48 : 60,
}}
className="relative flex shrink-0 items-center justify-center overflow-hidden rounded-lg border border-gray-100/50 bg-gradient-to-b from-[#f4f4f7] via-[#efeef2] to-[#E9EAF0] shadow-sm sm:rounded-xl dark:border-neutral-700 dark:from-neutral-700 dark:via-neutral-800 dark:to-neutral-900"
>
<motion.span className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_1px_1px_2px_rgba(255,255,255,0.8),_inset_-1px_-1px_2px_rgba(165,172,190,0.2)] dark:shadow-[inset_1px_1px_1px_rgba(255,255,255,0.1),inset_-1px_-1px_3px_rgba(0,0,0,0.6)]" />
<motion.div animate={{ scale: open ? 0.7 : 1 }}>
{headerIcon}
</motion.div>
</motion.div>
<div className="flex min-w-0 flex-1 flex-col justify-center">
<motion.p
layout
className="truncate text-[16px] font-bold tracking-tight text-neutral-900 sm:text-[17px] dark:text-neutral-100"
>
{title}
</motion.p>
<AnimatePresence mode="popLayout" initial={false}>
{!open && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.3,
ease: "easeOut",
}}
className="truncate text-[14px] tracking-tight text-[#BFBFC2] sm:text-[15px] dark:text-neutral-500"
>
{subtitle}
</motion.p>
)}
</AnimatePresence>
</div>
</div>
<motion.div
animate={{ rotate: open ? 180 : 0 }}
className="flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-[#9C97A8]/70 to-[#7A7596]/70 shadow-xs dark:from-neutral-700 dark:to-neutral-800"
>
<ChevronUpIcon className="size-5 text-white" />
</motion.div>
</motion.button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="border-t-2 border-[#e7e6e6]/60 dark:border-neutral-800"
>
<div className="py-2">
{activities.map((item, i) => (
<ActivityItem key={i} {...item} />
))}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</MotionConfig>
);
};
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+26
View File
@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
+709
View File
@@ -0,0 +1,709 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
interface IconProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
export function MapIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="96" y1="184" x2="96" y2="40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="160" y1="72" x2="160" y2="216" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polygon points="96 184 32 200 32 56 96 40 160 72 224 56 224 200 160 216 96 184" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function LayersIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><polyline points="32 176 128 232 224 176" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="32 128 128 184 224 128" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polygon points="32 80 128 136 224 80 128 24 32 80" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function FuelIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><path d="M56,216V56A16,16,0,0,1,72,40h80a16,16,0,0,1,16,16V216" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="32" y1="216" x2="192" y2="216" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M168,112h24a16,16,0,0,1,16,16v40a16,16,0,0,0,16,16h0a16,16,0,0,0,16-16V86.63a16,16,0,0,0-4.69-11.32L216,56" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="136" y1="112" x2="88" y2="112" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function BarChart3Icon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><polyline points="48 208 48 136 96 136" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="224" y1="208" x2="32" y2="208" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="96 208 96 88 152 88" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="152 208 152 40 208 40 208 208" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function BriefcaseIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="112" y1="112" x2="144" y2="112" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><rect x="32" y="64" width="192" height="144" rx="8" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M168,64V48a16,16,0,0,0-16-16H104A16,16,0,0,0,88,48V64" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M224,118.31A191.09,191.09,0,0,1,128,144a191.14,191.14,0,0,1-96-25.68" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function UsersIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><path d="M10.23,200a88,88,0,0,1,147.54,0" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M172,160a87.93,87.93,0,0,1,73.77,40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="84" cy="108" r="52" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M152.69,59.7A52,52,0,1,1,172,160" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function PlugIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="144" y1="64" x2="184" y2="24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="232" y1="72" x2="192" y2="112" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="224" y1="144" x2="112" y2="32" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M212,132l-58.63,58.63a32,32,0,0,1-45.25,0L65.37,147.88a32,32,0,0,1,0-45.25L124,44" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="86.75" y1="169.25" x2="32" y2="224" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function KeyRoundIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><path d="M93.17,122.83A71.68,71.68,0,0,1,88,95.91c0-38.58,31.08-70.64,69.64-71.87A72,72,0,0,1,232,98.36C230.73,136.92,198.67,168,160.09,168a71.68,71.68,0,0,1-26.92-5.17h0L120,176H96v24H72v24H40a8,8,0,0,1-8-8V187.31a8,8,0,0,1,2.34-5.65l58.83-58.83Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="180" cy="76" r="10"/>
</svg>
);
}
export function SettingsIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M41.43,178.09A99.14,99.14,0,0,1,31.36,153.8l16.78-21a81.59,81.59,0,0,1,0-9.64l-16.77-21a99.43,99.43,0,0,1,10.05-24.3l26.71-3a81,81,0,0,1,6.81-6.81l3-26.7A99.14,99.14,0,0,1,102.2,31.36l21,16.78a81.59,81.59,0,0,1,9.64,0l21-16.77a99.43,99.43,0,0,1,24.3,10.05l3,26.71a81,81,0,0,1,6.81,6.81l26.7,3a99.14,99.14,0,0,1,10.07,24.29l-16.78,21a81.59,81.59,0,0,1,0,9.64l16.77,21a99.43,99.43,0,0,1-10,24.3l-26.71,3a81,81,0,0,1-6.81,6.81l-3,26.7a99.14,99.14,0,0,1-24.29,10.07l-21-16.78a81.59,81.59,0,0,1-9.64,0l-21,16.77a99.43,99.43,0,0,1-24.3-10l-3-26.71a81,81,0,0,1-6.81-6.81Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function CreditCardIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><rect x="24" y="56" width="208" height="144" rx="8" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="168" y1="168" x2="200" y2="168" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="120" y1="168" x2="136" y2="168" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="24" y1="96" x2="232" y2="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function HelpCircleIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><circle cx="128" cy="180" r="10"/><path d="M128,144v-8c17.67,0,32-12.54,32-28s-14.33-28-32-28S96,92.54,96,108v4" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function BookOpenIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><path d="M128,88a32,32,0,0,1,32-32h72V200H160a32,32,0,0,0-32,32" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M24,200H96a32,32,0,0,1,32,32V88A32,32,0,0,0,96,56H24Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function SendIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="108" y1="148" x2="160" y2="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M223.69,42.18a8,8,0,0,0-9.87-9.87l-192,58.22a8,8,0,0,0-1.25,14.93L108,148l42.54,87.42a8,8,0,0,0,14.93-1.25Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function BellIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><path d="M96,192a32,32,0,0,0,64,0" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M56,104a72,72,0,0,1,144,0c0,35.82,8.3,64.6,14.9,76A8,8,0,0,1,208,192H48a8,8,0,0,1-6.88-12C47.71,168.6,56,139.81,56,104Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function SearchIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><circle cx="112" cy="112" r="80" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="168.57" y1="168.57" x2="224" y2="224" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function ArrowLeftIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="216" y1="128" x2="40" y2="128" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="112 56 40 128 112 200" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function TrendingDownIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><polyline points="232 192 136 96 96 136 24 64" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="232 128 232 192 168 192" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function InfoIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><path d="M120,120a8,8,0,0,1,8,8v40a8,8,0,0,0,8,8" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="124" cy="84" r="10"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function EyeIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><path d="M128,56C48,56,16,128,16,128s32,72,112,72,112-72,112-72S208,56,128,56Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="128" cy="128" r="40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function EyeOffIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="48" y1="40" x2="208" y2="216" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M154.91,157.6a40,40,0,0,1-53.82-59.2" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M135.53,88.71a40,40,0,0,1,32.3,35.53" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M208.61,169.1C230.41,149.58,240,128,240,128S208,56,128,56a126,126,0,0,0-20.68,1.68" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M74,68.6C33.23,89.24,16,128,16,128s32,72,112,72a118.05,118.05,0,0,0,54-12.6" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function ShieldAlertIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="128" y1="136" x2="128" y2="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="128" cy="172" r="10"/><path d="M216,112V56a8,8,0,0,0-8-8H48a8,8,0,0,0-8,8v56c0,96,88,120,88,120S216,208,216,112Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function MapPinIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><circle cx="128" cy="104" r="32" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M208,104c0,72-80,128-80,128S48,176,48,104a80,80,0,0,1,160,0Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function ZapIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><polygon points="160 16 144 96 208 120 96 240 112 160 48 136 160 16" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function ShoppingBagIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><rect x="32" y="48" width="192" height="160" rx="8" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M168,88a40,40,0,0,1-80,0" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function CoffeeIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><path d="M83.3,216A88,88,0,0,1,32,136V88H208v48a88,88,0,0,1-51.3,80" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="88" y1="24" x2="88" y2="56" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="120" y1="24" x2="120" y2="56" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="152" y1="24" x2="152" y2="56" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="32" y1="216" x2="208" y2="216" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M208,88h0a32,32,0,0,1,32,32v8a32,32,0,0,1-32,32h-3.38" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function SparklesIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><path d="M84.27,171.73l-55.09-20.3a7.92,7.92,0,0,1,0-14.86l55.09-20.3,20.3-55.09a7.92,7.92,0,0,1,14.86,0l20.3,55.09,55.09,20.3a7.92,7.92,0,0,1,0,14.86l-55.09,20.3-20.3,55.09a7.92,7.92,0,0,1-14.86,0Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="176" y1="16" x2="176" y2="64" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="224" y1="72" x2="224" y2="104" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="152" y1="40" x2="200" y2="40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="208" y1="88" x2="240" y2="88" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function ClockIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="128 72 128 128 184 128" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function NavigationIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><path d="M152,152,234.35,129a8,8,0,0,0,.27-15.21l-176-65.28A8,8,0,0,0,48.46,58.63l65.28,176a8,8,0,0,0,15.21-.27Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function XIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="200" y1="200" x2="56" y2="56" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function ChevronRightIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><polyline points="96 48 176 128 96 208" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function ChevronLeftIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><polyline points="160 208 80 128 160 48" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function AtSignIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M184,208c-15.21,10.11-36.37,16-56,16a96,96,0,1,1,96-96c0,22.09-8,40-28,40s-28-17.91-28-40V88" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function CircleCheckIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><polyline points="88 136 112 160 168 104" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function ArrowRightIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="40" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="144 56 216 128 144 200" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function UserPlusIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="200" y1="136" x2="248" y2="136" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="224" y1="112" x2="224" y2="160" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="108" cy="100" r="60" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M24,200c20.55-24.45,49.56-40,84-40s63.45,15.55,84,40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function FileTextIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><path d="M200,224H56a8,8,0,0,1-8-8V40a8,8,0,0,1,8-8h96l56,56V216A8,8,0,0,1,200,224Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="152 32 152 88 208 88" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="96" y1="136" x2="160" y2="136" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="96" y1="168" x2="160" y2="168" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function RocketIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="144" y1="224" x2="112" y2="224" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="128" cy="100" r="10"/><path d="M94.81,192C37.52,95.32,103.87,32.53,123.09,17.68a8,8,0,0,1,9.82,0C152.13,32.53,218.48,95.32,161.19,192Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M183.84,110.88l30.31,36.36a8,8,0,0,1,1.66,6.86l-12.36,55.63a8,8,0,0,1-12.81,4.51L161.19,192" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M72.16,110.88,41.85,147.24a8,8,0,0,0-1.66,6.86l12.36,55.63a8,8,0,0,0,12.81,4.51L94.81,192" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function MinusIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="40" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function TrendingUpIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><polyline points="232 56 136 152 96 112 24 184" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="232 120 232 56 168 56" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function ArrowUpIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="128" y1="216" x2="128" y2="40" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="56 112 128 40 200 112" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function ChevronUpIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><polyline points="48 160 128 80 208 160" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function ArrowDownIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="128" y1="40" x2="128" y2="216" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="56 144 128 216 200 144" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function ChevronDownIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><polyline points="208 96 128 176 48 96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function MoreHorizontalIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="10"/><circle cx="60" cy="128" r="10"/><circle cx="196" cy="128" r="10"/>
</svg>
);
}
export function CheckIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><polyline points="40 144 96 200 224 72" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function PanelLeftIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="88" y1="48" x2="88" y2="208" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><rect x="32" y="48" width="192" height="160" rx="8" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="32" y1="80" x2="56" y2="80" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="32" y1="112" x2="56" y2="112" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="32" y1="144" x2="56" y2="144" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function UserIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><circle cx="128" cy="96" r="64" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M32,216c19.37-33.47,54.55-56,96-56s76.63,22.53,96,56" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function LogOutIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><polyline points="112 40 48 40 48 216 112 216" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="112" y1="128" x2="224" y2="128" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="184 88 224 128 184 168" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function HandHeartIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><path d="M128,120a36,36,0,0,1,36-36c19.55,0,36,16.36,36,36v4a68,68,0,0,1-68,68H116" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M72,168H48a8,8,0,0,1-8-8V128a8,8,0,0,1,8-8H72" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M72,168a32,32,0,0,0,32,32H176" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M72,120v48" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M148,84a20,20,0,0,0-20,20" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M108.28,44.72a24,24,0,0,1,39.44,0L164,68H92Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function RoadHorizonIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><line x1="8" y1="200" x2="248" y2="200" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="128" y1="120" x2="128" y2="136" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="128" y1="168" x2="128" y2="200" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="96" y1="72" x2="160" y2="72" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="64" y1="200" x2="120" y2="72" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><line x1="192" y1="200" x2="136" y2="72" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function GlobeHemisphereWestIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M186.36,187.64l-26-9.33a8,8,0,0,1-5.23-5.63L150.24,153a8,8,0,0,1,1.68-7.27l22.77-26.67A8,8,0,0,1,180.8,116l18.83,2.68" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><path d="M89.55,43.35l5.31,17.72a8,8,0,0,1-2.87,8.56L71.33,84.94a8,8,0,0,0-2.69,8.84l7.36,22.09a8,8,0,0,1-2.71,8.79l-32,25.14" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function WalletIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><path d="M40,56V184a16,16,0,0,0,16,16H216a8,8,0,0,0,8-8V80a8,8,0,0,0-8-8H56A16,16,0,0,1,40,56h0A16,16,0,0,1,56,40H192" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><circle cx="180" cy="132" r="10"/>
</svg>
);
}
export function HouseIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><path d="M104,216V152h48v64h64V120a8,8,0,0,0-2.34-5.66l-80-80a8,8,0,0,0-11.32,0l-80,80A8,8,0,0,0,40,120v96Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
export function CaretDoubleRightIcon({ className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className={cn("h-4 w-4 shrink-0 fill-none stroke-currentColor", className)}
{...props}
>
<rect width="256" height="256" fill="none"/><polyline points="56 48 136 128 56 208" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/><polyline points="136 48 216 128 136 208" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="12"/>
</svg>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+138
View File
@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "@/components/ui/phosphor-icons"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+725
View File
@@ -0,0 +1,725 @@
"use client"
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "@/components/ui/phosphor-icons"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
"data-active": isActive ? "true" : undefined,
} as any,
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
"data-active": isActive ? "true" : undefined,
} as any,
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+66
View File
@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }