First commit

This commit is contained in:
dyzulk
2025-12-30 12:11:04 +07:00
commit 34dc111344
322 changed files with 31972 additions and 0 deletions

View File

@@ -0,0 +1,213 @@
'use client';
import { useState } from 'react';
import ComponentCard from './common/ComponentCard';
import { useTranslations } from 'next-intl';
// Simple Tab Component since we're using Tailwind directly
function Tabs({ tabs }: { tabs: { label: string; content: React.ReactNode }[] }) {
const [activeTab, setActiveTab] = useState(0);
return (
<div className="w-full">
<div className="flex overflow-x-auto border-b border-gray-200 dark:border-gray-800">
{tabs.map((tab, index) => (
<button
key={index}
onClick={() => setActiveTab(index)}
className={`px-4 py-2 text-sm font-medium whitespace-nowrap focus:outline-none transition-colors duration-200 ${
activeTab === index
? 'text-blue-600 border-b-2 border-blue-600 dark:text-blue-500 dark:border-blue-500'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-b-2 border-transparent'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="p-4 bg-gray-50 dark:bg-white/[0.02] rounded-b-lg mt-0">
{tabs[activeTab].content}
</div>
</div>
);
}
function CodeBlock({ code }: { code: string }) {
return (
<pre className="p-3 bg-gray-900 text-gray-100 rounded text-sm overflow-x-auto font-mono custom-scrollbar">
<code>{code}</code>
</pre>
);
}
export default function ApiUsageDocs({ apiKey = 'YOUR_API_KEY' }: { apiKey?: string }) {
const t = useTranslations("ApiKeys");
// Determine Base URL dynamically, ensuring it ends with /api/v1
const envApiUrl = process.env.NEXT_PUBLIC_API_URL || 'https://trustlab-api.dyzulk.com/api';
const baseUrl = envApiUrl.endsWith('/v1') ? envApiUrl : `${envApiUrl}/v1`;
// Snippet Generators
const snippets = [
{
label: 'cURL',
code: `curl -X GET "${baseUrl}/certificates" \\
-H "TRUSTLAB_API_KEY: ${apiKey}" \\
-H "Accept: application/json"`
},
{
label: 'PHP',
code: `<?php
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => '${baseUrl}/certificates',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => '',
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => array(
'TRUSTLAB_API_KEY: ${apiKey}',
'Accept: application/json'
),
));
$response = curl_exec($curl);
curl_close($curl);
echo $response;`
},
{
label: 'Python',
code: `import requests
url = "${baseUrl}/certificates"
payload={}
headers = {
'TRUSTLAB_API_KEY': '${apiKey}',
'Accept': 'application/json'
}
response = requests.request("GET", url, headers=headers, data=payload)
print(response.text)`
},
{
label: 'Node.js',
code: `const axios = require('axios');
let config = {
method: 'get',
maxBodyLength: Infinity,
url: '${baseUrl}/certificates',
headers: {
'TRUSTLAB_API_KEY': '${apiKey}',
'Accept': 'application/json'
}
};
axios.request(config)
.then((response) => {
console.log(JSON.stringify(response.data));
})
.catch((error) => {
console.log(error);
});`
},
{
label: 'JavaScript',
code: `var myHeaders = new Headers();
myHeaders.append("TRUSTLAB_API_KEY", "${apiKey}");
myHeaders.append("Accept", "application/json");
var requestOptions = {
method: 'GET',
headers: myHeaders,
redirect: 'follow'
};
fetch("${baseUrl}/certificates", requestOptions)
.then(response => response.text())
.then(result => console.log(result))
.catch(error => console.log('error', error));`
},
{
label: 'Ruby',
code: `require "uri"
require "net/http"
url = URI("${baseUrl}/certificates")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["TRUSTLAB_API_KEY"] = "${apiKey}"
request["Accept"] = "application/json"
response = http.request(request)
puts response.read_body`
},
{
label: 'Go',
code: `package main
import (
"fmt"
"net/http"
"io/ioutil"
)
func main() {
url := "${baseUrl}/certificates"
method := "GET"
client := &http.Client {
}
req, err := http.NewRequest(method, url, nil)
if err != nil {
fmt.Println(err)
return
}
req.Header.Add("TRUSTLAB_API_KEY", "${apiKey}")
req.Header.Add("Accept", "application/json")
res, err := client.Do(req)
if err != nil {
fmt.Println(err)
return
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(body))
}`
}
];
const tabs = snippets.map(s => ({
label: s.label,
content: <CodeBlock code={s.code} />
}));
return (
<ComponentCard title={t("usage_title")} desc={t("usage_desc")}>
<div>
<p className="mb-4 text-gray-600 dark:text-gray-400">
{t("usage_p")}
</p>
<Tabs tabs={tabs} />
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
interface LegalPageLink {
title: string;
slug: string;
}
export default function Footer() {
const [legalPages, setLegalPages] = useState<LegalPageLink[]>([]);
useEffect(() => {
const fetchLegalPages = async () => {
try {
// Use public API endpoint
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const res = await fetch(`${apiUrl}/api/public/legal-pages`, {
headers: { 'Accept': 'application/json' }
});
if (res.ok) {
const json = await res.json();
setLegalPages(json.data);
}
} catch (error) {
console.error("Failed to fetch footer links", error);
}
};
fetchLegalPages();
}, []);
return (
<footer className="py-12 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div className="flex flex-wrap justify-center gap-6 mb-6 text-gray-500 dark:text-gray-400 text-sm font-medium">
<Link href="/contact" className="hover:text-brand-500 transition-colors">Contact</Link>
{legalPages.map((page) => (
<Link
key={page.slug}
href={`/legal/view?slug=${page.slug}`}
className="hover:text-brand-500 transition-colors"
>
{page.title}
</Link>
))}
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm font-medium">
&copy; 2023-{new Date().getFullYear()} TrustLab by <a href="https://www.dyzulk.com" target="_blank" rel="noopener noreferrer" className="hover:text-brand-500 transition-colors">DyzulkDev</a>. Built for security and performance.
</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,225 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { ThemeToggle } from "@/components/common/ThemeToggle";
import { useAuth } from "@/hooks/useAuth";
import { LanguageSwitcher } from "@/components/header/LanguageSwitcher";
import { useTranslations } from "next-intl";
// Helper for smooth scrolling
const scrollToSection = (id: string) => {
const element = document.querySelector(id);
if (!element) return;
const navbarOffset = 80;
const elementPosition = element.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - navbarOffset;
window.scrollTo({
top: offsetPosition,
behavior: "smooth",
});
};
export default function Navbar() {
const [mobileOpen, setMobileOpen] = useState(false);
const [toolsOpen, setToolsOpen] = useState(false);
const pathname = usePathname();
const { user, logout } = useAuth();
const t = useTranslations("Navigation");
// Theme toggle logic simplified for this component or reused from context
// Assuming we might want to lift theme state up or use a provider.
// For now, I'll add a placeholder toggle or just basic class toggle logic if not globally provided yet.
// Note: The Header.tsx used local state or a store. Let's assume a simple toggle for now.
return (
<nav className="fixed top-0 left-0 right-0 z-50 w-full bg-white/80 dark:bg-gray-900/80 backdrop-blur-lg border-b border-gray-100 dark:border-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-20">
{/* Logo */}
{/* Logo */}
<Link href="/" className="flex items-center gap-3">
{/* Using the migrated asset */}
<div className="relative w-10 h-10">
<img src="/images/logo/logo-icon.svg" alt="TrustLab Logo" className="w-full h-full object-contain" />
</div>
<span className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-500 dark:from-white dark:to-gray-400">
TrustLab
</span>
</Link>
{/* Desktop Menu */}
<div className="hidden md:flex items-center gap-8 text-sm font-medium">
<Link href="/" className="text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors">{t("home")}</Link>
<a
href="/#features"
onClick={(e) => {
if (pathname === '/') {
e.preventDefault();
scrollToSection('#features');
}
}}
className="text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors cursor-pointer"
>
{t("features")}
</a>
{/* Tools Dropdown */}
<div
className="relative"
onMouseEnter={() => setToolsOpen(true)}
onMouseLeave={() => setToolsOpen(false)}
>
<button
onClick={(e) => {
e.preventDefault();
setToolsOpen(!toolsOpen);
}}
className="flex items-center gap-1 text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors py-2"
>
{t("tools")}
<svg className={`w-4 h-4 transition-transform duration-200 ${toolsOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{toolsOpen && (
<div className="absolute left-0 top-full pt-3 w-64 z-50">
<div className="rounded-2xl bg-white dark:bg-gray-800 shadow-xl border border-gray-100 dark:border-gray-700 py-2 overflow-hidden">
<Link href="/tools/chat-id" className="flex items-center gap-3 px-4 py-3 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:text-brand-500 transition-all">
<div className="w-8 h-8 bg-brand-50 dark:bg-brand-500/10 rounded-lg flex items-center justify-center text-brand-500">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
</div>
<div>
<div className="font-bold">{t("chat_id_finder")}</div>
<div className="text-[10px] text-gray-400">Find your Telegram ID</div>
</div>
</Link>
<Link href="/tools/key-generator" className="flex items-center gap-3 px-4 py-3 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:text-brand-500 transition-all">
<div className="w-8 h-8 bg-blue-50 dark:bg-blue-500/10 rounded-lg flex items-center justify-center text-blue-500">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
</div>
<div>
<div className="font-bold">{t("app_key_generator")}</div>
<div className="text-[10px] text-gray-400">Secure Laravel keys</div>
</div>
</Link>
</div>
</div>
)}
</div>
<Link href="/contact" className="text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors">{t("contact")}</Link>
{user ? (
<Link href="/dashboard" className="text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors font-bold">
Hi, {user.first_name || 'User'}
</Link>
) : (
<Link href="/signin" className="text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors">{t("signin")}</Link>
)}
<LanguageSwitcher />
<ThemeToggle />
{user ? (
<button
onClick={logout}
className="px-5 py-2.5 bg-gray-100 dark:bg-white/5 text-gray-700 dark:text-white rounded-xl font-semibold transition-all hover:bg-gray-200 dark:hover:bg-white/10"
>
{t("signout")}
</button>
) : (
<Link href="/signup" className="px-5 py-2.5 bg-brand-500 hover:bg-brand-600 text-white rounded-xl font-semibold shadow-lg shadow-brand-500/25 transition-all hover:scale-105">
{t("signup")}
</Link>
)}
</div>
{/* Mobile Header Actions */}
<div className="md:hidden flex items-center gap-2">
<LanguageSwitcher />
<ThemeToggle />
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2 text-gray-600 dark:text-gray-400">
{mobileOpen ? (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16m-7 6h7" />
</svg>
)}
</button>
</div>
</div>
</div>
{/* Mobile Navigation Overlay */}
{mobileOpen && (
<div className="fixed top-0 left-0 w-full h-[100dvh] z-40 md:hidden bg-white dark:bg-gray-900 pt-24 px-6 pb-24 animate-in slide-in-from-top-10 fade-in duration-300 overflow-y-auto">
<div className="flex flex-col gap-6 pb-10">
<div className="flex justify-end">
<button
onClick={() => setMobileOpen(false)}
className="p-2 -mr-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label="Close menu"
>
<span className="text-sm font-medium mr-2">Close</span>
<svg className="inline-block w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<Link href="/" onClick={() => setMobileOpen(false)} className="text-lg font-bold text-gray-900 dark:text-white border-b border-gray-100 dark:border-gray-800 pb-4">{t("home")}</Link>
<a
href="/#features"
onClick={(e) => {
setMobileOpen(false);
if (pathname === '/') {
e.preventDefault();
scrollToSection('#features');
}
}}
className="text-lg font-bold text-gray-900 dark:text-white border-b border-gray-100 dark:border-gray-800 pb-4"
>
{t("features")}
</a>
{/* Mobile Tools (simplified) */}
<div className="space-y-4 border-b border-gray-100 dark:border-gray-800 pb-4">
<div className="text-lg font-bold text-gray-900 dark:text-white">{t("tools")}</div>
<div className="pl-4 space-y-3">
<Link href="/tools/chat-id" onClick={() => setMobileOpen(false)} className="block text-gray-600 dark:text-gray-400 font-medium">{t("chat_id_finder")}</Link>
<Link href="/tools/key-generator" onClick={() => setMobileOpen(false)} className="block text-gray-600 dark:text-gray-400 font-medium">{t("app_key_generator")}</Link>
</div>
</div>
<Link href="/contact" onClick={() => setMobileOpen(false)} className="text-lg font-bold text-gray-900 dark:text-white border-b border-gray-100 dark:border-gray-800 pb-4">{t("contact")}</Link>
{user ? (
<>
<Link href="/dashboard" onClick={() => setMobileOpen(false)} className="text-lg font-bold text-brand-500 border-b border-gray-100 dark:border-gray-800 pb-4">{t("dashboard")}</Link>
<button
onClick={() => {
setMobileOpen(false);
logout();
}}
className="text-lg font-bold text-red-500 text-left"
>
{t("signout")}
</button>
</>
) : (
<>
<Link href="/signin" onClick={() => setMobileOpen(false)} className="text-lg font-bold text-gray-900 dark:text-white border-b border-gray-100 dark:border-gray-800 pb-4">{t("signin")}</Link>
<Link href="/signup" onClick={() => setMobileOpen(false)} className="mt-6 w-full py-4 bg-brand-500 text-white rounded-2xl font-bold text-center shadow-xl shadow-brand-500/20 block">{t("signup")}</Link>
</>
)}
</div>
</div>
)}
</nav>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import Navbar from "./Public/Navbar";
import Footer from "./Public/Footer";
export default function PublicLayout({ children }: { children: React.ReactNode }) {
return (
<div className="relative min-h-screen bg-white dark:bg-gray-900 transition-colors duration-300 overflow-hidden font-sans flex flex-col">
{/* Background Glow Effects (Glow in the dark) */}
<div className="fixed inset-0 pointer-events-none -z-10 overflow-hidden">
<div className="absolute top-0 left-0 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-brand-500/10 dark:bg-brand-500/20 rounded-full blur-[120px] opacity-20 dark:opacity-60 transition-opacity duration-500"></div>
<div className="absolute top-1/2 right-0 translate-x-1/2 -translate-y-1/2 w-[700px] h-[700px] bg-blue-500/5 dark:bg-brand-500/10 rounded-full blur-[150px] opacity-10 dark:opacity-40 transition-opacity duration-500"></div>
<div className="absolute bottom-0 left-0 -translate-x-1/4 translate-y-1/4 w-[500px] h-[500px] bg-brand-500/10 dark:bg-brand-500/20 rounded-full blur-[100px] opacity-20 dark:opacity-50 transition-opacity duration-500"></div>
</div>
<Navbar />
<main className="relative z-10 flex-grow flex flex-col">
{children}
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import React, { useState, useMemo } from "react";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
} from "../ui/table";
import Badge from "../ui/badge/Badge";
import Button from "../ui/button/Button";
import InputField from "../form/input/InputField";
import { useTranslations } from "next-intl";
interface CaCertificate {
uuid: string;
ca_type: string;
common_name: string;
serial_number: string;
valid_from: string;
valid_to: string;
status: string;
}
interface RootCaTableProps {
certificates: CaCertificate[];
onRenew: (uuid: string) => void;
isRenewing: boolean;
}
export default function RootCaTable({
certificates,
onRenew,
isRenewing,
}: RootCaTableProps) {
const t = useTranslations("RootCA");
const [searchTerm, setSearchTerm] = useState("");
const formatType = (type: string) => {
return type.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
};
const filteredCertificates = useMemo(() => {
return certificates.filter((cert) => {
const search = searchTerm.toLowerCase();
return (
cert.common_name.toLowerCase().includes(search) ||
cert.ca_type.toLowerCase().includes(search) ||
cert.serial_number.toLowerCase().includes(search)
);
});
}, [certificates, searchTerm]);
return (
<div className="space-y-4">
{/* Table Header Controls */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-end px-1">
<div className="w-full sm:w-64">
<InputField
placeholder={t("search_placeholder")}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
className="!py-2"
/>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
<div className="max-w-full overflow-x-auto">
<Table>
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
<TableRow>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
{t("type_th")}
</TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
{t("common_name_th")}
</TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
{t("serial_th")}
</TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
{t("validity_th")}
</TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
{t("status_th")}
</TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">
{t("actions_th")}
</TableCell>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
{filteredCertificates.map((cert) => (
<TableRow key={cert.uuid} className="hover:bg-gray-50 dark:hover:bg-white/[0.02] transition-colors">
<TableCell className="px-5 py-4 text-start font-medium text-gray-800 dark:text-white/90">
{formatType(cert.ca_type)}
</TableCell>
<TableCell className="px-5 py-4 text-start text-theme-sm text-gray-600 dark:text-gray-400">
{cert.common_name}
</TableCell>
<TableCell className="px-5 py-4 text-start font-mono text-theme-xs text-gray-500 dark:text-gray-400">
{cert.serial_number}
</TableCell>
<TableCell className="px-5 py-4 text-start text-theme-xs text-gray-500 dark:text-gray-400">
<div className="flex flex-col">
<span>{new Date(cert.valid_from).toLocaleString()}</span>
<span className="text-gray-400">{t("to")}</span>
<span>{new Date(cert.valid_to).toLocaleString()}</span>
</div>
</TableCell>
<TableCell className="px-5 py-4 text-start">
<Badge size="sm" color={cert.status === "valid" ? "success" : "error"}>
{cert.status === "valid" ? t("status_valid") : t("status_expired")}
</Badge>
</TableCell>
<TableCell className="px-5 py-4 text-center">
<Button size="sm" onClick={() => onRenew(cert.uuid)} loading={isRenewing}>
{t("renew_button")}
</Button>
</TableCell>
</TableRow>
))}
{filteredCertificates.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="px-5 py-10 text-center text-gray-500 dark:text-gray-400">
{searchTerm ? t("no_ca_search", { term: searchTerm }) : t("no_ca_found")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,264 @@
"use client";
import React, { useState } from "react";
import useSWR, { mutate } from "swr";
import axios from "@/lib/axios";
import ComponentCard from "../common/ComponentCard";
import Button from "../ui/button/Button";
import Badge from "../ui/badge/Badge";
import { TrashBinIcon, CopyIcon, CheckLineIcon, PlusIcon, LockIcon, BoltIcon } from "@/icons";
import { useToast } from "@/context/ToastContext";
import ConfirmationModal from "../common/ConfirmationModal";
import { useTranslations } from "next-intl";
interface ApiKey {
id: string;
name: string;
is_active: boolean;
created_at: string;
last_used_at: string | null;
}
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
export default function ApiKeyManagement() {
const t = useTranslations("ApiKeys");
const { data, error, isLoading } = useSWR("/api/api-keys", fetcher);
const [isCreating, setIsCreating] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [generatedKey, setGeneratedKey] = useState<string | null>(null);
const [isRevoking, setIsRevoking] = useState<string | null>(null);
const [isToggling, setIsToggling] = useState<string | null>(null);
const [isRegenerating, setIsRegenerating] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { addToast } = useToast();
// Confirmation states
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
const [confirmRegenerateId, setConfirmRegenerateId] = useState<string | null>(null);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newKeyName.trim()) return;
setIsCreating(true);
try {
const response = await axios.post("/api/api-keys", { name: newKeyName });
setGeneratedKey(response.data.token);
setNewKeyName("");
mutate("/api/api-keys");
addToast(t("toast_gen_success"), "success");
} catch (err) {
addToast(t("toast_gen_failed"), "error");
} finally {
setIsCreating(false);
}
};
const handleRevoke = async (id: string) => {
setIsRevoking(id);
try {
await axios.delete(`/api/api-keys/${id}`);
mutate("/api/api-keys");
addToast(t("toast_revoke_success"), "success");
setConfirmRevokeId(null);
} catch (err) {
addToast(t("toast_revoke_failed"), "error");
} finally {
setIsRevoking(null);
}
};
const handleToggle = async (id: string) => {
setIsToggling(id);
try {
await axios.patch(`/api/api-keys/${id}/toggle`);
mutate("/api/api-keys");
addToast(t("toast_status_updated"), "success");
} catch (err) {
addToast(t("toast_status_failed"), "error");
} finally {
setIsToggling(null);
}
};
const handleRegenerate = async (id: string) => {
setIsRegenerating(id);
try {
const response = await axios.post(`/api/api-keys/${id}/regenerate`);
setGeneratedKey(response.data.token);
mutate("/api/api-keys");
addToast(t("toast_regen_success"), "success");
setConfirmRegenerateId(null);
} catch (err) {
addToast(t("toast_regen_failed"), "error");
} finally {
setIsRegenerating(null);
}
};
const copyToClipboard = () => {
if (generatedKey) {
navigator.clipboard.writeText(generatedKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const keys = data?.data || [];
return (
<div className="space-y-6">
<ComponentCard
title={t("gen_title")}
desc={t("gen_desc")}
>
{!generatedKey ? (
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<input
type="text"
placeholder={t("input_placeholder")}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="w-full px-4 py-2 rounded-lg border border-gray-200 dark:border-gray-800 bg-transparent dark:bg-white/[0.03] text-gray-800 dark:text-white/90 focus:ring-brand-500 focus:border-brand-500"
required
/>
</div>
<Button
type="submit"
loading={isCreating}
disabled={!newKeyName.trim()}
>
<PlusIcon className="w-4 h-4 mr-2" />
{t("btn_generate")}
</Button>
</form>
) : (
<div className="p-4 rounded-xl bg-brand-50 dark:bg-brand-500/10 border border-brand-100 dark:border-brand-500/20">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-brand-700 dark:text-brand-400">{t("your_key")}</span>
<span className="text-xs text-brand-600 dark:text-brand-500 font-normal italic">{t("copy_warning")}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 bg-white dark:bg-gray-900 px-4 py-3 rounded-lg border border-brand-200 dark:border-brand-500/30 text-gray-800 dark:text-white font-mono text-sm break-all">
{generatedKey}
</div>
<button
onClick={copyToClipboard}
className="p-3 bg-white dark:bg-gray-800 border border-brand-200 dark:border-brand-500/30 rounded-lg text-brand-500 hover:bg-brand-50 dark:hover:bg-brand-500/10 transition-colors"
title={t("copy_tooltip")}
>
{copied ? <CheckLineIcon className="w-5 h-5" /> : <CopyIcon className="w-5 h-5" />}
</button>
</div>
<div className="mt-4 flex justify-end">
<Button variant="outline" onClick={() => setGeneratedKey(null)}>
{t("btn_done")}
</Button>
</div>
</div>
)}
</ComponentCard>
<ComponentCard title={t("active_title")} desc={t("active_desc")}>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-100 dark:border-gray-800">
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">{t("th_name")}</th>
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">{t("th_status")}</th>
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">{t("th_created")}</th>
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">{t("th_last_used")}</th>
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider text-right">{t("th_actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{isLoading ? (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-gray-500">{t("loading_keys")}</td>
</tr>
) : keys.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-gray-500 italic">{t("no_keys")}</td>
</tr>
) : (
keys.map((key: ApiKey) => (
<tr key={key.id} className="hover:bg-gray-50 dark:hover:bg-white/[0.02] transition-colors">
<td className="px-4 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-gray-500">
<LockIcon className="w-4 h-4" />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-white/90">{key.name}</span>
</div>
</td>
<td className="px-4 py-4">
<button
onClick={() => handleToggle(key.id)}
disabled={isToggling === key.id}
title={key.is_active ? t("tooltip_deactivate") : t("tooltip_activate")}
>
<Badge color={key.is_active ? "success" : "warning"}>
{isToggling === key.id ? "..." : (key.is_active ? t("status_active") : t("status_inactive"))}
</Badge>
</button>
</td>
<td className="px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
{new Date(key.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
{key.last_used_at ? new Date(key.last_used_at).toLocaleString() : t("never_used")}
</td>
<td className="px-4 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setConfirmRegenerateId(key.id)}
disabled={isRegenerating === key.id}
className="p-2 text-gray-400 hover:text-brand-500 transition-colors disabled:opacity-50"
title={t("tooltip_regenerate")}
>
<BoltIcon className={`w-5 h-5 ${isRegenerating === key.id ? "animate-spin" : ""}`} />
</button>
<button
onClick={() => setConfirmRevokeId(key.id)}
disabled={isRevoking === key.id}
className="p-2 text-gray-400 hover:text-error-500 transition-colors disabled:opacity-50"
title={t("tooltip_revoke")}
>
<TrashBinIcon className="w-5 h-5" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</ComponentCard>
<ConfirmationModal
isOpen={confirmRevokeId !== null}
onClose={() => setConfirmRevokeId(null)}
onConfirm={() => confirmRevokeId && handleRevoke(confirmRevokeId)}
title={t("revoke_title")}
message={t("revoke_msg")}
isLoading={isRevoking !== null}
confirmLabel={t("revoke_confirm")}
requiredInput="REVOKE"
/>
<ConfirmationModal
isOpen={confirmRegenerateId !== null}
onClose={() => setConfirmRegenerateId(null)}
onConfirm={() => confirmRegenerateId && handleRegenerate(confirmRegenerateId)}
title={t("regen_title")}
message={t("regen_msg")}
isLoading={isRegenerating !== null}
confirmLabel={t("regen_confirm")}
variant="warning"
/>
</div>
);
}

View File

@@ -0,0 +1,157 @@
"use client";
import React, { useState } from "react";
import { useTranslations } from "next-intl";
import ComponentCard from "../common/ComponentCard";
import Badge from "../ui/badge/Badge";
import { CopyIcon, CheckLineIcon, DownloadIcon } from "@/icons";
interface Certificate {
uuid: string;
common_name: string;
organization: string;
locality: string;
state: string;
country: string;
san: string;
key_bits: number;
serial_number: string;
cert_content: string;
key_content: string;
csr_content: string;
valid_from: string;
valid_to: string;
}
interface CertificateDetailsProps {
certificate: Certificate;
}
export default function CertificateDetails({ certificate }: CertificateDetailsProps) {
const t = useTranslations("Certificates");
const [copiedSection, setCopiedSection] = useState<string | null>(null);
const copyToClipboard = (text: string, section: string) => {
navigator.clipboard.writeText(text);
setCopiedSection(section);
setTimeout(() => setCopiedSection(null), 2000);
};
const isExpired = new Date(certificate.valid_to) < new Date();
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Metadata Card */}
<ComponentCard title={t("metadata_title")} desc={t("metadata_desc")}>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">{t("status_label")}</span>
<Badge size="sm" color={isExpired ? "error" : "success"}>
{isExpired ? t("status_expired") : t("status_valid")}
</Badge>
</div>
<DetailRow label={t("cn_label")} value={certificate.common_name} />
<DetailRow label={t("org_field_label")} value={certificate.organization || "-"} />
<DetailRow label={t("locality_label")} value={certificate.locality || "-"} />
<DetailRow label={t("state_label")} value={certificate.state || "-"} />
<DetailRow label={t("country_label")} value={certificate.country || "-"} />
<DetailRow label={t("key_strength_label")} value={t("bits_val", { bits: certificate.key_bits })} />
<DetailRow label={t("serial_label")} value={certificate.serial_number} mono />
<DetailRow label={t("san_field_label")} value={certificate.san || "-"} mono />
<div className="pt-4 border-t border-gray-100 dark:border-gray-800">
<DetailRow label={t("valid_from_label")} value={new Date(certificate.valid_from).toLocaleString()} />
<DetailRow label={t("valid_to_label")} value={new Date(certificate.valid_to).toLocaleString()} />
</div>
</div>
</ComponentCard>
{/* PEM Content Sections */}
<div className="space-y-6">
<PemSection
title={t("crt_title")}
content={certificate.cert_content}
sectionId="cert"
onCopy={copyToClipboard}
isCopied={copiedSection === "cert"}
/>
<PemSection
title={t("key_title")}
content={certificate.key_content}
sectionId="key"
onCopy={copyToClipboard}
isCopied={copiedSection === "key"}
isSecret
/>
{certificate.csr_content && (
<PemSection
title={t("csr_title")}
content={certificate.csr_content}
sectionId="csr"
onCopy={copyToClipboard}
isCopied={copiedSection === "csr"}
/>
)}
</div>
</div>
);
}
function DetailRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between py-1">
<span className="text-sm text-gray-500 dark:text-gray-400">{label}</span>
<span className={`text-sm font-medium text-gray-800 dark:text-white/90 ${mono ? "font-mono break-all" : ""}`}>
{value}
</span>
</div>
);
}
function PemSection({
title,
content,
sectionId,
onCopy,
isCopied,
isSecret = false
}: {
title: string;
content: string;
sectionId: string;
onCopy: (text: string, id: string) => void;
isCopied: boolean;
isSecret?: boolean;
}) {
const [showSecret, setShowSecret] = useState(!isSecret);
return (
<ComponentCard
title={title}
headerAction={
<div className="flex items-center gap-3">
{isSecret && (
<button
onClick={() => setShowSecret(!showSecret)}
className="flex items-center justify-center px-3 py-1.5 text-xs font-medium bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md text-gray-700 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 shadow-xs transition-colors"
>
{showSecret ? "Hide" : "Show"}
</button>
)}
<button
onClick={() => onCopy(content, sectionId)}
className="flex items-center justify-center p-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md text-gray-700 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 shadow-xs transition-colors"
title="Copy to Clipboard"
>
{isCopied ? <CheckLineIcon className="w-4 h-4 text-success-500" /> : <CopyIcon className="w-4 h-4" />}
</button>
</div>
}
>
<div className={`h-48 overflow-auto rounded-lg bg-gray-50 p-4 dark:bg-gray-900/50 border border-gray-100 dark:border-gray-800 custom-scrollbar ${isSecret && !showSecret ? "filter blur-sm select-none" : ""}`}>
<pre className="text-xs font-mono leading-relaxed text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-all">
{content}
</pre>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,308 @@
"use client";
import React, { useState, useMemo } from "react";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
} from "../ui/table";
import Badge from "../ui/badge/Badge";
import { DownloadIcon, TrashBinIcon, EyeIcon } from "@/icons";
import Link from "next/link";
import InputField from "../form/input/InputField";
import { useTranslations } from "next-intl";
interface Certificate {
uuid: string;
common_name: string;
organization: string;
serial_number: string;
san: string;
valid_from: string;
valid_to: string;
key_bits: number;
}
interface CertificateTableProps {
certificates: Certificate[];
onDelete: (uuid: string) => void;
}
export default function CertificateTable({
certificates,
onDelete,
}: CertificateTableProps) {
const t = useTranslations("Certificates");
const [searchTerm, setSearchTerm] = useState("");
const [entriesPerPage, setEntriesPerPage] = useState(10);
const [currentPage, setCurrentPage] = useState(1);
const [statusFilter, setStatusFilter] = useState("all");
const [issuanceFilter, setIssuanceFilter] = useState("all");
const isExpired = (validTo: string) => {
if (!validTo) return false;
return new Date(validTo) < new Date();
};
// Filtering logic
const filteredCertificates = useMemo(() => {
return certificates.filter((cert) => {
const search = searchTerm.toLowerCase();
// Search matching
const matchesSearch =
cert.common_name.toLowerCase().includes(search) ||
(cert.organization && cert.organization.toLowerCase().includes(search)) ||
(cert.serial_number && cert.serial_number.toLowerCase().includes(search)) ||
getIntermediateName(cert.key_bits).toLowerCase().includes(search) ||
`${cert.key_bits}`.includes(search);
// Status matching
const expired = isExpired(cert.valid_to);
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "valid" && !expired) ||
(statusFilter === "expired" && expired);
// Issuance matching
const matchesIssuance =
issuanceFilter === "all" ||
(issuanceFilter === "2048" && cert.key_bits === 2048) ||
(issuanceFilter === "4096" && cert.key_bits === 4096);
return matchesSearch && matchesStatus && matchesIssuance;
});
}, [certificates, searchTerm, statusFilter, issuanceFilter]);
const getIntermediateName = (bits: number) => {
return bits === 4096 ? "Intermediate 4096" : "Intermediate 2048";
};
// Pagination logic
const totalPages = Math.ceil(filteredCertificates.length / entriesPerPage);
const paginatedCertificates = useMemo(() => {
const start = (currentPage - 1) * entriesPerPage;
return filteredCertificates.slice(start, start + entriesPerPage);
}, [filteredCertificates, currentPage, entriesPerPage]);
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages) {
setCurrentPage(page);
}
};
return (
<div className="space-y-4">
{/* Table Controls */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between px-1">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">{t("filter_show")}</span>
<select
value={entriesPerPage}
onChange={(e) => {
setEntriesPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="px-2 py-1.5 text-sm text-gray-700 dark:text-gray-400 border border-gray-200 rounded-md dark:border-gray-800 bg-white dark:bg-gray-900 focus:ring-2 focus:ring-brand-500 outline-none"
>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
</select>
</div>
<div className="h-6 w-[1px] bg-gray-200 dark:bg-gray-800 hidden sm:block"></div>
<div className="flex items-center gap-4">
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setCurrentPage(1); }}
className="px-3 py-1.5 text-sm text-gray-700 dark:text-gray-400 border border-gray-200 rounded-md dark:border-gray-800 bg-white dark:bg-gray-900 focus:ring-2 focus:ring-brand-500 outline-none min-w-[120px]"
>
<option value="all">{t("filter_all_status")}</option>
<option value="valid">{t("status_valid")}</option>
<option value="expired">{t("status_expired")}</option>
</select>
<select
value={issuanceFilter}
onChange={(e) => { setIssuanceFilter(e.target.value); setCurrentPage(1); }}
className="px-3 py-1.5 text-sm text-gray-700 dark:text-gray-400 border border-gray-200 rounded-md dark:border-gray-800 bg-white dark:bg-gray-900 focus:ring-2 focus:ring-brand-500 outline-none min-w-[120px]"
>
<option value="all">{t("filter_all_issuance")}</option>
<option value="2048">Int 2048</option>
<option value="4096">Int 4096</option>
</select>
{(statusFilter !== "all" || issuanceFilter !== "all" || searchTerm !== "") && (
<button
onClick={() => {
setStatusFilter("all");
setIssuanceFilter("all");
setSearchTerm("");
setCurrentPage(1);
}}
className="text-sm text-brand-500 hover:text-brand-600 font-medium"
>
{t("filter_reset")}
</button>
)}
</div>
</div>
<div className="w-full sm:w-64">
<InputField
placeholder={t("filter_search_placeholder")}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="!py-2"
/>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
<div className="max-w-full overflow-x-auto">
<Table>
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
<TableRow>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
{t("th_common_name")}
</TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
{t("th_serial")}
</TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
{t("th_issuance")}
</TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
{t("th_status")}
</TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
{t("th_validity")}
</TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">
{t("th_actions")}
</TableCell>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
{paginatedCertificates.map((cert) => (
<TableRow key={cert.uuid} className="hover:bg-gray-50 dark:hover:bg-white/[0.02] transition-colors">
<TableCell className="px-5 py-4 text-start">
<div>
<span className="block font-medium text-gray-800 text-theme-sm dark:text-white/90">
{cert.common_name}
</span>
<span className="block text-gray-500 text-theme-xs dark:text-gray-400">
{cert.organization || t("no_org")}
</span>
</div>
</TableCell>
<TableCell className="px-5 py-4 text-start font-mono text-theme-xs text-gray-500 dark:text-gray-400">
{cert.serial_number || "-"}
</TableCell>
<TableCell className="px-5 py-4 text-start">
<div className="flex flex-col">
<span className="text-theme-xs font-medium text-gray-800 dark:text-white/90">
{getIntermediateName(cert.key_bits)}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t("bits_suffix", { bits: cert.key_bits })}
</span>
</div>
</TableCell>
<TableCell className="px-5 py-4 text-start">
<Badge size="sm" color={isExpired(cert.valid_to) ? "error" : "success"}>
{isExpired(cert.valid_to) ? t("status_expired") : t("status_valid")}
</Badge>
</TableCell>
<TableCell className="px-5 py-4 text-start text-theme-xs text-gray-500 dark:text-gray-400">
<div className="flex flex-col">
<span>{t("valid_from_prefix")}{cert.valid_from ? new Date(cert.valid_from).toLocaleDateString() : "-"}</span>
<span>{t("valid_to_prefix")}{cert.valid_to ? new Date(cert.valid_to).toLocaleDateString() : "-"}</span>
</div>
</TableCell>
<TableCell className="px-5 py-4 text-center">
<div className="flex items-center justify-center gap-2">
<Link
href={`/dashboard/certificates/view?uuid=${cert.uuid}`}
className="p-2 text-gray-500 hover:text-brand-500 transition-colors"
title={t("tooltip_view")}
>
<EyeIcon className="w-5 h-5" />
</Link>
<button
onClick={() => onDelete(cert.uuid)}
className="p-2 text-gray-500 hover:text-error-500 transition-colors"
title={t("tooltip_delete")}
>
<TrashBinIcon className="w-5 h-5" />
</button>
</div>
</TableCell>
</TableRow>
))}
{paginatedCertificates.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="px-5 py-10 text-center text-gray-500 dark:text-gray-400">
{searchTerm ? t("no_search_results", { term: searchTerm }) : t("no_certs")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-1">
<span className="text-sm text-gray-500">
{t("pagination_showing", {
start: ((currentPage - 1) * entriesPerPage) + 1,
end: Math.min(currentPage * entriesPerPage, filteredCertificates.length),
total: filteredCertificates.length
})}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-1 text-sm rounded-md border border-gray-200 dark:border-gray-800 disabled:opacity-50 hover:bg-gray-50 dark:hover:bg-white/5 transition"
>
{t("pagination_prev")}
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => handlePageChange(p)}
className={`px-3 py-1 text-sm rounded-md border transition ${
currentPage === p
? "bg-brand-500 text-white border-brand-500"
: "border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-white/5"
}`}
>
{p}
</button>
))}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm rounded-md border border-gray-200 dark:border-gray-800 disabled:opacity-50 hover:bg-gray-50 dark:hover:bg-white/5 transition"
>
{t("pagination_next")}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,253 @@
"use client";
import React, { useState } from "react";
import useSWR from "swr";
import { Modal } from "../ui/modal";
import axios from "@/lib/axios";
import Button from "../ui/button/Button";
import Checkbox from "@/components/form/input/Checkbox";
import { useToast } from "@/context/ToastContext";
import { useTranslations } from "next-intl";
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
interface CreateCertificateModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
defaults?: any;
}
export default function CreateCertificateModal({
isOpen,
onClose,
onSuccess,
defaults
}: CreateCertificateModalProps) {
const t = useTranslations("Certificates");
const { addToast } = useToast();
const { data: user } = useSWR("/api/user", fetcher);
const [loading, setLoading] = useState(false);
const [configMode, setConfigMode] = useState<"default" | "manual">("default");
const [isTestShortLived, setIsTestShortLived] = useState(false);
const [formData, setFormData] = useState({
common_name: "",
organization: defaults?.organizationName || "",
locality: defaults?.localityName || "",
state: defaults?.stateOrProvinceName || "",
country: defaults?.countryName || "ID",
key_bits: "2048",
san: "",
});
// Reset form when modal opens
React.useEffect(() => {
if (isOpen) {
setFormData({
common_name: "",
organization: defaults?.organizationName || "",
locality: defaults?.localityName || "",
state: defaults?.stateOrProvinceName || "",
country: defaults?.countryName || "ID",
key_bits: "2048",
san: "",
});
setConfigMode("default");
setIsTestShortLived(false);
}
}, [isOpen, defaults]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await axios.post("/api/certificates", {
...formData,
config_mode: configMode,
is_test_short_lived: isTestShortLived,
});
addToast(t("toast_gen_success"), "success");
onSuccess();
onClose();
} catch (err: any) {
console.error(err);
addToast(err.response?.data?.message || t("toast_gen_failed"), "error");
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} className="max-w-3xl">
<div className="p-6">
<h3 className="mb-2 text-xl font-bold text-gray-800 dark:text-white/90">
{t("modal_title")}
</h3>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
{t("modal_desc")}
</p>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="col-span-1 sm:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("common_name_label")}
</label>
<input
type="text"
required
placeholder={t("common_name_placeholder")}
className="w-full px-4 py-2 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 text-gray-800 dark:text-white/90 placeholder:text-gray-400 dark:placeholder:text-white/30 focus:ring-2 focus:ring-brand-500 outline-none transition"
value={formData.common_name}
onChange={(e) => setFormData({ ...formData, common_name: e.target.value })}
/>
</div>
<div className="col-span-1 sm:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("san_label")}
</label>
<textarea
placeholder={t("san_placeholder")}
className="w-full px-4 py-2 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 text-gray-800 dark:text-white/90 placeholder:text-gray-400 dark:placeholder:text-white/30 focus:ring-2 focus:ring-brand-500 outline-none transition h-20"
value={formData.san}
onChange={(e) => setFormData({ ...formData, san: e.target.value })}
/>
<p className="mt-1 text-xs text-gray-500">
{t("san_hint")}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("key_strength_label")}
</label>
<select
className="w-full px-4 py-2 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 text-gray-800 dark:text-white/90 focus:ring-2 focus:ring-brand-500 outline-none transition"
value={formData.key_bits}
onChange={(e) => setFormData({ ...formData, key_bits: e.target.value })}
>
<option value="2048" className="dark:bg-gray-900">{t("key_2048")}</option>
<option value="4096" className="dark:bg-gray-900">{t("key_4096")}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("config_mode_label")}
</label>
<div className="flex items-center gap-4 py-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={configMode === "default"}
onChange={() => setConfigMode("default")}
className="w-4 h-4 text-brand-500 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-400">{t("config_default")}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={configMode === "manual"}
onChange={() => setConfigMode("manual")}
className="w-4 h-4 text-brand-500 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-400">{t("config_manual")}</span>
</label>
</div>
</div>
{configMode === "manual" && (
<>
<div className="col-span-1 sm:col-span-2 pt-4 border-t border-gray-100 dark:border-gray-800">
<h4 className="text-sm font-bold text-gray-800 dark:text-gray-200">{t("manual_fields_title")}</h4>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("org_label")}
</label>
<input
type="text"
placeholder="e.g. TrustLab Inc"
className="w-full px-4 py-2 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 text-gray-800 dark:text-white/90 placeholder:text-gray-400 dark:placeholder:text-white/30 focus:ring-2 focus:ring-brand-500 outline-none transition"
value={formData.organization}
onChange={(e) => setFormData({ ...formData, organization: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("locality_label")}
</label>
<input
type="text"
placeholder="e.g. Jakarta"
className="w-full px-4 py-2 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 text-gray-800 dark:text-white/90 placeholder:text-gray-400 dark:placeholder:text-white/30 focus:ring-2 focus:ring-brand-500 outline-none transition"
value={formData.locality}
onChange={(e) => setFormData({ ...formData, locality: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("state_label")}
</label>
<input
type="text"
placeholder="e.g. DKI Jakarta"
className="w-full px-4 py-2 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 text-gray-800 dark:text-white/90 placeholder:text-gray-400 dark:placeholder:text-white/30 focus:ring-2 focus:ring-brand-500 outline-none transition"
value={formData.state}
onChange={(e) => setFormData({ ...formData, state: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("country_label")}
</label>
<input
type="text"
maxLength={2}
className="w-full px-4 py-2 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 focus:ring-2 focus:ring-brand-500 outline-none transition"
value={formData.country}
onChange={(e) => setFormData({ ...formData, country: e.target.value.toUpperCase() })}
/>
</div>
</>
)}
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 pt-6 border-t border-gray-100 dark:border-gray-800">
<div>
{(user?.role === 'admin' || user?.role === 'owner') && (
<div className="flex items-center gap-2">
<Checkbox
checked={isTestShortLived}
onChange={(checked) => setIsTestShortLived(checked as boolean)}
label={t("test_mode_label")}
/>
<span className="text-xs text-red-500 bg-red-50 dark:bg-red-900/10 px-2 py-0.5 rounded font-mono">ADMIN ONLY</span>
</div>
)}
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={onClose}
type="button"
>
{t("cancel")}
</Button>
<Button
type="submit"
loading={loading}
>
{t("btn_generate")}
</Button>
</div>
</div>
</form>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,45 @@
import React, { useState } from "react";
const ChartTab: React.FC = () => {
const [selected, setSelected] = useState<
"optionOne" | "optionTwo" | "optionThree"
>("optionOne");
const getButtonClass = (option: "optionOne" | "optionTwo" | "optionThree") =>
selected === option
? "shadow-theme-xs text-gray-900 dark:text-white bg-white dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400";
return (
<div className="flex items-center gap-0.5 rounded-lg bg-gray-100 p-0.5 dark:bg-gray-900">
<button
onClick={() => setSelected("optionOne")}
className={`px-3 py-2 font-medium w-full rounded-md text-theme-sm hover:text-gray-900 dark:hover:text-white ${getButtonClass(
"optionOne"
)}`}
>
Monthly
</button>
<button
onClick={() => setSelected("optionTwo")}
className={`px-3 py-2 font-medium w-full rounded-md text-theme-sm hover:text-gray-900 dark:hover:text-white ${getButtonClass(
"optionTwo"
)}`}
>
Quarterly
</button>
<button
onClick={() => setSelected("optionThree")}
className={`px-3 py-2 font-medium w-full rounded-md text-theme-sm hover:text-gray-900 dark:hover:text-white ${getButtonClass(
"optionThree"
)}`}
>
Annually
</button>
</div>
);
};
export default ChartTab;

View File

@@ -0,0 +1,17 @@
import React from "react";
export default function CommonGridShape() {
return (
<div className="absolute inset-0 z-0 opacity-10">
{/* Simple grid pattern SVG or similar, replacing the image for now if static */}
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="currentColor" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import React from "react";
interface ComponentCardProps {
title?: string;
children: React.ReactNode;
className?: string; // Additional custom classes for styling
desc?: string; // Description text
headerAction?: React.ReactNode; // Optional action in the header
}
const ComponentCard: React.FC<ComponentCardProps> = ({
title,
children,
className = "",
desc = "",
headerAction,
}) => {
return (
<div
className={`rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] ${className}`}
>
{/* Card Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 sm:px-7 py-5 gap-4 sm:gap-0">
<div>
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
{title}
</h3>
{desc && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{desc}
</p>
)}
</div>
{headerAction && (
<div className="flex-shrink-0 w-full sm:w-auto">
{headerAction}
</div>
)}
</div>
{/* Card Body */}
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="space-y-6">{children}</div>
</div>
</div>
);
};
export default ComponentCard;

View File

@@ -0,0 +1,130 @@
"use client";
import React from "react";
import { Modal } from "../ui/modal";
import Button from "../ui/button/Button";
import { AlertIcon } from "@/icons";
interface ConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
isLoading?: boolean;
confirmLabel?: string;
cancelLabel?: string;
variant?: "danger" | "warning" | "info";
requiredInput?: string; // Text that must be entered to enable the confirm button
requiredInputPlaceholder?: string;
}
const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
isLoading = false,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
variant = "danger",
requiredInput,
requiredInputPlaceholder = "Type to confirm...",
}) => {
const [inputValue, setInputValue] = React.useState("");
// Reset input when modal opens/closes
React.useEffect(() => {
if (!isOpen) {
setInputValue("");
}
}, [isOpen]);
const isConfirmDisabled = isLoading || (requiredInput !== undefined && inputValue !== requiredInput);
const getVariantStyles = () => {
switch (variant) {
case "danger":
return {
iconBg: "bg-error-50 dark:bg-error-500/10",
iconColor: "text-error-600 dark:text-error-500",
confirmBtn: "primary", // Assuming Button variant
confirmBtnClass: "bg-error-600 hover:bg-error-700 text-white border-none",
};
case "warning":
return {
iconBg: "bg-warning-50 dark:bg-warning-500/10",
iconColor: "text-warning-600 dark:text-warning-500",
confirmBtn: "primary",
confirmBtnClass: "bg-warning-600 hover:bg-warning-700 text-white border-none",
};
default:
return {
iconBg: "bg-brand-50 dark:bg-brand-500/10",
iconColor: "text-brand-600 dark:text-brand-500",
confirmBtn: "primary",
confirmBtnClass: "",
};
}
};
const styles = getVariantStyles();
return (
<Modal isOpen={isOpen} onClose={onClose} className="max-w-[440px]" showCloseButton={true}>
<div className="p-6">
<div className="flex flex-col items-center text-center">
<div className={`mb-4 flex h-14 w-14 items-center justify-center rounded-full ${styles.iconBg}`}>
<AlertIcon className={`h-7 w-7 ${styles.iconColor}`} />
</div>
<h3 className="mb-2 text-xl font-bold text-gray-800 dark:text-white/90">
{title}
</h3>
<p className="mb-8 text-sm text-gray-500 dark:text-gray-400">
{message}
</p>
{requiredInput !== undefined && (
<div className="mb-6 w-full text-left">
<label className="mb-2 block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Type <span className="text-gray-800 dark:text-white font-bold">"{requiredInput}"</span> to confirm
</label>
<input
type="text"
className="w-full rounded-lg border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-hidden transition focus:border-brand-500 dark:border-gray-800 dark:text-white"
placeholder={requiredInputPlaceholder}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
autoFocus
/>
</div>
)}
<div className="flex w-full flex-col gap-3 sm:flex-row">
<Button
className="w-full sm:flex-1"
variant="outline"
onClick={onClose}
disabled={isLoading}
>
{cancelLabel}
</Button>
<Button
className={`w-full sm:flex-1 ${styles.confirmBtnClass}`}
onClick={onConfirm}
loading={isLoading}
disabled={isConfirmDisabled}
>
{confirmLabel}
</Button>
</div>
</div>
</div>
</Modal>
);
};
export default ConfirmationModal;

View File

@@ -0,0 +1,25 @@
import Image from "next/image";
import React from "react";
export default function GridShape() {
return (
<>
<div className="absolute right-0 top-0 -z-1 w-full max-w-[250px] xl:max-w-[450px]">
<Image
width={540}
height={254}
src="/images/shape/grid-01.svg"
alt="grid"
/>
</div>
<div className="absolute bottom-0 left-0 -z-1 w-full max-w-[250px] rotate-180 xl:max-w-[450px]">
<Image
width={540}
height={254}
src="/images/shape/grid-01.svg"
alt="grid"
/>
</div>
</>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import React, { useState, useCallback } from "react";
import Cropper from "react-easy-crop";
import Button from "../ui/button/Button";
import { Modal } from "../ui/modal";
interface ImageCropperProps {
image: string;
isOpen: boolean;
onClose: () => void;
onCropComplete: (croppedImage: Blob) => void;
aspectRatio?: number;
}
const ImageCropper: React.FC<ImageCropperProps> = ({
image,
isOpen,
onClose,
onCropComplete,
aspectRatio = 1,
}) => {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<any>(null);
const onCropChange = useCallback((crop: any) => {
setCrop(crop);
}, []);
const onZoomChange = useCallback((zoom: any) => {
setZoom(zoom);
}, []);
const onCropCompleteInternal = useCallback(
(croppedArea: any, croppedAreaPixels: any) => {
setCroppedAreaPixels(croppedAreaPixels);
},
[]
);
const createImage = (url: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => resolve(image));
image.addEventListener("error", (error) => reject(error));
image.setAttribute("crossOrigin", "anonymous");
image.src = url;
});
const getCroppedImg = async (
imageSrc: string,
pixelCrop: any
): Promise<Blob | null> => {
const image = await createImage(imageSrc);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return null;
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
);
return new Promise((resolve) => {
canvas.toBlob((blob) => {
resolve(blob);
}, "image/png");
});
};
const showCroppedImage = useCallback(async () => {
try {
const croppedImage = await getCroppedImg(image, croppedAreaPixels);
if (croppedImage) {
onCropComplete(croppedImage);
onClose();
}
} catch (e) {
console.error(e);
}
}, [croppedAreaPixels, image, onCropComplete, onClose]);
return (
<Modal isOpen={isOpen} onClose={onClose} className="max-w-[600px]">
<div className="p-6">
<h3 className="mb-4 text-lg font-semibold text-gray-800 dark:text-white/90">
Crop Your Avatar
</h3>
<div className="relative h-80 w-full overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-800">
<Cropper
image={image}
crop={crop}
zoom={zoom}
aspect={aspectRatio}
onCropChange={onCropChange}
onCropComplete={onCropCompleteInternal}
onZoomChange={onZoomChange}
/>
</div>
<div className="mt-6 flex items-center gap-4">
<div className="flex-1">
<input
type="range"
value={zoom}
min={1}
max={3}
step={0.1}
aria-labelledby="Zoom"
onChange={(e) => setZoom(Number(e.target.value))}
className="w-full"
/>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={showCroppedImage}>Update Avatar</Button>
</div>
</div>
</div>
</Modal>
);
};
export default ImageCropper;

View File

@@ -0,0 +1,52 @@
import Link from "next/link";
import React from "react";
interface BreadcrumbProps {
pageTitle: string;
}
const PageBreadcrumb: React.FC<BreadcrumbProps> = ({ pageTitle }) => {
return (
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<h2
className="text-xl font-semibold text-gray-800 dark:text-white/90"
x-text="pageName"
>
{pageTitle}
</h2>
<nav>
<ol className="flex items-center gap-1.5">
<li>
<Link
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400"
href="/dashboard"
>
Home
<svg
className="stroke-current"
width="17"
height="16"
viewBox="0 0 17 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.0765 12.667L10.2432 8.50033L6.0765 4.33366"
stroke=""
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Link>
</li>
<li className="text-sm text-gray-800 dark:text-white/90">
{pageTitle}
</li>
</ol>
</nav>
</div>
);
};
export default PageBreadcrumb;

View File

@@ -0,0 +1,11 @@
"use client";
import React from "react";
export default function Preloader() {
return (
<div className="flex h-full min-h-[60vh] w-full items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-solid border-brand-500 border-t-transparent"></div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import { useTheme } from "@/context/ThemeContext";
export function ThemeToggle() {
const { toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="relative flex items-center justify-center text-gray-500 transition-colors bg-white border border-gray-200 rounded-full hover:text-dark-900 h-11 w-11 hover:bg-gray-100 hover:text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label="Toggle theme"
>
<svg
className="hidden dark:block"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.99998 1.5415C10.4142 1.5415 10.75 1.87729 10.75 2.2915V3.5415C10.75 3.95572 10.4142 4.2915 9.99998 4.2915C9.58577 4.2915 9.24998 3.95572 9.24998 3.5415V2.2915C9.24998 1.87729 9.58577 1.5415 9.99998 1.5415ZM10.0009 6.79327C8.22978 6.79327 6.79402 8.22904 6.79402 10.0001C6.79402 11.7712 8.22978 13.207 10.0009 13.207C11.772 13.207 13.2078 11.7712 13.2078 10.0001C13.2078 8.22904 11.772 6.79327 10.0009 6.79327ZM5.29402 10.0001C5.29402 7.40061 7.40135 5.29327 10.0009 5.29327C12.6004 5.29327 14.7078 7.40061 14.7078 10.0001C14.7078 12.5997 12.6004 14.707 10.0009 14.707C7.40135 14.707 5.29402 12.5997 5.29402 10.0001ZM15.9813 5.08035C16.2742 4.78746 16.2742 4.31258 15.9813 4.01969C15.6884 3.7268 15.2135 3.7268 14.9207 4.01969L14.0368 4.90357C13.7439 5.19647 13.7439 5.67134 14.0368 5.96423C14.3297 6.25713 14.8045 6.25713 15.0974 5.96423L15.9813 5.08035ZM18.4577 10.0001C18.4577 10.4143 18.1219 10.7501 17.7077 10.7501H16.4577C16.0435 10.7501 15.7077 10.4143 15.7077 10.0001C15.7077 9.58592 16.0435 9.25013 16.4577 9.25013H17.7077C18.1219 9.25013 18.4577 9.58592 18.4577 10.0001ZM14.9207 15.9806C15.2135 16.2735 15.6884 16.2735 15.9813 15.9806C16.2742 15.6877 16.2742 15.2128 15.9813 14.9199L15.0974 14.036C14.8045 13.7431 14.3297 13.7431 14.0368 14.036C13.7439 14.3289 13.7439 14.8038 14.0368 15.0967L14.9207 15.9806ZM9.99998 15.7088C10.4142 15.7088 10.75 16.0445 10.75 16.4588V17.7088C10.75 18.123 10.4142 18.4588 9.99998 18.4588C9.58577 18.4588 9.24998 18.123 9.24998 17.7088V16.4588C9.24998 16.0445 9.58577 15.7088 9.99998 15.7088ZM5.96356 15.0972C6.25646 14.8043 6.25646 14.3295 5.96356 14.0366C5.67067 13.7437 5.1958 13.7437 4.9029 14.0366L4.01902 14.9204C3.72613 15.2133 3.72613 15.6882 4.01902 15.9811C4.31191 16.274 4.78679 16.274 5.07968 15.9811L5.96356 15.0972ZM4.29224 10.0001C4.29224 10.4143 3.95645 10.7501 3.54224 10.7501H2.29224C1.87802 10.7501 1.54224 10.4143 1.54224 10.0001C1.54224 9.58592 1.87802 9.25013 2.29224 9.25013H3.54224C3.95645 9.25013 4.29224 9.58592 4.29224 10.0001ZM4.9029 5.9637C5.1958 6.25659 5.67067 6.25659 5.96356 5.9637C6.25646 5.6708 6.25646 5.19593 5.96356 4.90303L5.07968 4.01915C4.78679 3.72626 4.31191 3.72626 4.01902 4.01915C3.72613 4.31204 3.72613 4.78692 4.01902 5.07981L4.9029 5.9637Z"
fill="currentColor"
/>
</svg>
<svg
className="dark:hidden"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.4547 11.97L18.1799 12.1611C18.265 11.8383 18.1265 11.4982 17.8401 11.3266C17.5538 11.1551 17.1885 11.1934 16.944 11.4207L17.4547 11.97ZM8.0306 2.5459L8.57989 3.05657C8.80718 2.81209 8.84554 2.44682 8.67398 2.16046C8.50243 1.8741 8.16227 1.73559 7.83948 1.82066L8.0306 2.5459ZM12.9154 13.0035C9.64678 13.0035 6.99707 10.3538 6.99707 7.08524H5.49707C5.49707 11.1823 8.81835 14.5035 12.9154 14.5035V13.0035ZM16.944 11.4207C15.8869 12.4035 14.4721 13.0035 12.9154 13.0035V14.5035C14.8657 14.5035 16.6418 13.7499 17.9654 12.5193L16.944 11.4207ZM16.7295 11.7789C15.9437 14.7607 13.2277 16.9586 10.0003 16.9586V18.4586C13.9257 18.4586 17.2249 15.7853 18.1799 12.1611L16.7295 11.7789ZM10.0003 16.9586C6.15734 16.9586 3.04199 13.8433 3.04199 10.0003H1.54199C1.54199 14.6717 5.32892 18.4586 10.0003 18.4586V16.9586ZM3.04199 10.0003C3.04199 6.77289 5.23988 4.05695 8.22173 3.27114L7.83948 1.82066C4.21532 2.77574 1.54199 6.07486 1.54199 10.0003H3.04199ZM6.99707 7.08524C6.99707 5.52854 7.5971 4.11366 8.57989 3.05657L7.48132 2.03522C6.25073 3.35885 5.49707 5.13487 5.49707 7.08524H6.99707Z"
fill="currentColor"
/>
</svg>
</button>
);
}

View File

@@ -0,0 +1,42 @@
import React from "react";
import { useTheme } from "../../context/ThemeContext";
export const ThemeToggleButton: React.FC = () => {
const { toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="relative flex items-center justify-center text-gray-500 transition-colors bg-white border border-gray-200 rounded-full hover:text-dark-900 h-11 w-11 hover:bg-gray-100 hover:text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
>
<svg
className="hidden dark:block"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.99998 1.5415C10.4142 1.5415 10.75 1.87729 10.75 2.2915V3.5415C10.75 3.95572 10.4142 4.2915 9.99998 4.2915C9.58577 4.2915 9.24998 3.95572 9.24998 3.5415V2.2915C9.24998 1.87729 9.58577 1.5415 9.99998 1.5415ZM10.0009 6.79327C8.22978 6.79327 6.79402 8.22904 6.79402 10.0001C6.79402 11.7712 8.22978 13.207 10.0009 13.207C11.772 13.207 13.2078 11.7712 13.2078 10.0001C13.2078 8.22904 11.772 6.79327 10.0009 6.79327ZM5.29402 10.0001C5.29402 7.40061 7.40135 5.29327 10.0009 5.29327C12.6004 5.29327 14.7078 7.40061 14.7078 10.0001C14.7078 12.5997 12.6004 14.707 10.0009 14.707C7.40135 14.707 5.29402 12.5997 5.29402 10.0001ZM15.9813 5.08035C16.2742 4.78746 16.2742 4.31258 15.9813 4.01969C15.6884 3.7268 15.2135 3.7268 14.9207 4.01969L14.0368 4.90357C13.7439 5.19647 13.7439 5.67134 14.0368 5.96423C14.3297 6.25713 14.8045 6.25713 15.0974 5.96423L15.9813 5.08035ZM18.4577 10.0001C18.4577 10.4143 18.1219 10.7501 17.7077 10.7501H16.4577C16.0435 10.7501 15.7077 10.4143 15.7077 10.0001C15.7077 9.58592 16.0435 9.25013 16.4577 9.25013H17.7077C18.1219 9.25013 18.4577 9.58592 18.4577 10.0001ZM14.9207 15.9806C15.2135 16.2735 15.6884 16.2735 15.9813 15.9806C16.2742 15.6877 16.2742 15.2128 15.9813 14.9199L15.0974 14.036C14.8045 13.7431 14.3297 13.7431 14.0368 14.036C13.7439 14.3289 13.7439 14.8038 14.0368 15.0967L14.9207 15.9806ZM9.99998 15.7088C10.4142 15.7088 10.75 16.0445 10.75 16.4588V17.7088C10.75 18.123 10.4142 18.4588 9.99998 18.4588C9.58577 18.4588 9.24998 18.123 9.24998 17.7088V16.4588C9.24998 16.0445 9.58577 15.7088 9.99998 15.7088ZM5.96356 15.0972C6.25646 14.8043 6.25646 14.3295 5.96356 14.0366C5.67067 13.7437 5.1958 13.7437 4.9029 14.0366L4.01902 14.9204C3.72613 15.2133 3.72613 15.6882 4.01902 15.9811C4.31191 16.274 4.78679 16.274 5.07968 15.9811L5.96356 15.0972ZM4.29224 10.0001C4.29224 10.4143 3.95645 10.7501 3.54224 10.7501H2.29224C1.87802 10.7501 1.54224 10.4143 1.54224 10.0001C1.54224 9.58592 1.87802 9.25013 2.29224 9.25013H3.54224C3.95645 9.25013 4.29224 9.58592 4.29224 10.0001ZM4.9029 5.9637C5.1958 6.25659 5.67067 6.25659 5.96356 5.9637C6.25646 5.6708 6.25646 5.19593 5.96356 4.90303L5.07968 4.01915C4.78679 3.72626 4.31191 3.72626 4.01902 4.01915C3.72613 4.31204 3.72613 4.78692 4.01902 5.07981L4.9029 5.9637Z"
fill="currentColor"
/>
</svg>
<svg
className="dark:hidden"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.4547 11.97L18.1799 12.1611C18.265 11.8383 18.1265 11.4982 17.8401 11.3266C17.5538 11.1551 17.1885 11.1934 16.944 11.4207L17.4547 11.97ZM8.0306 2.5459L8.57989 3.05657C8.80718 2.81209 8.84554 2.44682 8.67398 2.16046C8.50243 1.8741 8.16227 1.73559 7.83948 1.82066L8.0306 2.5459ZM12.9154 13.0035C9.64678 13.0035 6.99707 10.3538 6.99707 7.08524H5.49707C5.49707 11.1823 8.81835 14.5035 12.9154 14.5035V13.0035ZM16.944 11.4207C15.8869 12.4035 14.4721 13.0035 12.9154 13.0035V14.5035C14.8657 14.5035 16.6418 13.7499 17.9654 12.5193L16.944 11.4207ZM16.7295 11.7789C15.9437 14.7607 13.2277 16.9586 10.0003 16.9586V18.4586C13.9257 18.4586 17.2249 15.7853 18.1799 12.1611L16.7295 11.7789ZM10.0003 16.9586C6.15734 16.9586 3.04199 13.8433 3.04199 10.0003H1.54199C1.54199 14.6717 5.32892 18.4586 10.0003 18.4586V16.9586ZM3.04199 10.0003C3.04199 6.77289 5.23988 4.05695 8.22173 3.27114L7.83948 1.82066C4.21532 2.77574 1.54199 6.07486 1.54199 10.0003H3.04199ZM6.99707 7.08524C6.99707 5.52854 7.5971 4.11366 8.57989 3.05657L7.48132 2.03522C6.25073 3.35885 5.49707 5.13487 5.49707 7.08524H6.99707Z"
fill="currentColor"
/>
</svg>
</button>
);
};

View File

@@ -0,0 +1,42 @@
"use client";
import { useTheme } from "@/context/ThemeContext";
import React from "react";
export default function ThemeTogglerTwo() {
const { toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="inline-flex size-14 items-center justify-center rounded-full bg-brand-500 text-white transition-colors hover:bg-brand-600"
>
<svg
className="hidden dark:block"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.99998 1.5415C10.4142 1.5415 10.75 1.87729 10.75 2.2915V3.5415C10.75 3.95572 10.4142 4.2915 9.99998 4.2915C9.58577 4.2915 9.24998 3.95572 9.24998 3.5415V2.2915C9.24998 1.87729 9.58577 1.5415 9.99998 1.5415ZM10.0009 6.79327C8.22978 6.79327 6.79402 8.22904 6.79402 10.0001C6.79402 11.7712 8.22978 13.207 10.0009 13.207C11.772 13.207 13.2078 11.7712 13.2078 10.0001C13.2078 8.22904 11.772 6.79327 10.0009 6.79327ZM5.29402 10.0001C5.29402 7.40061 7.40135 5.29327 10.0009 5.29327C12.6004 5.29327 14.7078 7.40061 14.7078 10.0001C14.7078 12.5997 12.6004 14.707 10.0009 14.707C7.40135 14.707 5.29402 12.5997 5.29402 10.0001ZM15.9813 5.08035C16.2742 4.78746 16.2742 4.31258 15.9813 4.01969C15.6884 3.7268 15.2135 3.7268 14.9207 4.01969L14.0368 4.90357C13.7439 5.19647 13.7439 5.67134 14.0368 5.96423C14.3297 6.25713 14.8045 6.25713 15.0974 5.96423L15.9813 5.08035ZM18.4577 10.0001C18.4577 10.4143 18.1219 10.7501 17.7077 10.7501H16.4577C16.0435 10.7501 15.7077 10.4143 15.7077 10.0001C15.7077 9.58592 16.0435 9.25013 16.4577 9.25013H17.7077C18.1219 9.25013 18.4577 9.58592 18.4577 10.0001ZM14.9207 15.9806C15.2135 16.2735 15.6884 16.2735 15.9813 15.9806C16.2742 15.6877 16.2742 15.2128 15.9813 14.9199L15.0974 14.036C14.8045 13.7431 14.3297 13.7431 14.0368 14.036C13.7439 14.3289 13.7439 14.8038 14.0368 15.0967L14.9207 15.9806ZM9.99998 15.7088C10.4142 15.7088 10.75 16.0445 10.75 16.4588V17.7088C10.75 18.123 10.4142 18.4588 9.99998 18.4588C9.58577 18.4588 9.24998 18.123 9.24998 17.7088V16.4588C9.24998 16.0445 9.58577 15.7088 9.99998 15.7088ZM5.96356 15.0972C6.25646 14.8043 6.25646 14.3295 5.96356 14.0366C5.67067 13.7437 5.1958 13.7437 4.9029 14.0366L4.01902 14.9204C3.72613 15.2133 3.72613 15.6882 4.01902 15.9811C4.31191 16.274 4.78679 16.274 5.07968 15.9811L5.96356 15.0972ZM4.29224 10.0001C4.29224 10.4143 3.95645 10.7501 3.54224 10.7501H2.29224C1.87802 10.7501 1.54224 10.4143 1.54224 10.0001C1.54224 9.58592 1.87802 9.25013 2.29224 9.25013H3.54224C3.95645 9.25013 4.29224 9.58592 4.29224 10.0001ZM4.9029 5.9637C5.1958 6.25659 5.67067 6.25659 5.96356 5.9637C6.25646 5.6708 6.25646 5.19593 5.96356 4.90303L5.07968 4.01915C4.78679 3.72626 4.31191 3.72626 4.01902 4.01915C3.72613 4.31204 3.72613 4.78692 4.01902 5.07981L4.9029 5.9637Z"
fill="currentColor"
/>
</svg>
<svg
className="dark:hidden"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.4547 11.97L18.1799 12.1611C18.265 11.8383 18.1265 11.4982 17.8401 11.3266C17.5538 11.1551 17.1885 11.1934 16.944 11.4207L17.4547 11.97ZM8.0306 2.5459L8.57989 3.05657C8.80718 2.81209 8.84554 2.44682 8.67398 2.16046C8.50243 1.8741 8.16227 1.73559 7.83948 1.82066L8.0306 2.5459ZM12.9154 13.0035C9.64678 13.0035 6.99707 10.3538 6.99707 7.08524H5.49707C5.49707 11.1823 8.81835 14.5035 12.9154 14.5035V13.0035ZM16.944 11.4207C15.8869 12.4035 14.4721 13.0035 12.9154 13.0035V14.5035C14.8657 14.5035 16.6418 13.7499 17.9654 12.5193L16.944 11.4207ZM16.7295 11.7789C15.9437 14.7607 13.2277 16.9586 10.0003 16.9586V18.4586C13.9257 18.4586 17.2249 15.7853 18.1799 12.1611L16.7295 11.7789ZM10.0003 16.9586C6.15734 16.9586 3.04199 13.8433 3.04199 10.0003H1.54199C1.54199 14.6717 5.32892 18.4586 10.0003 18.4586V16.9586ZM3.04199 10.0003C3.04199 6.77289 5.23988 4.05695 8.22173 3.27114L7.83948 1.82066C4.21532 2.77574 1.54199 6.07486 1.54199 10.0003H3.04199ZM6.99707 7.08524C6.99707 5.52854 7.5971 4.11366 8.57989 3.05657L7.48132 2.03522C6.25073 3.35885 5.49707 5.13487 5.49707 7.08524H6.99707Z"
fill="currentColor"
/>
</svg>
</button>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import React from "react";
import Badge from "../ui/badge/Badge";
import { ArrowDownIcon, ArrowUpIcon, BoxIconLine, GroupIcon } from "@/icons";
export const EcommerceMetrics = () => {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-6">
{/* <!-- Metric Item Start --> */}
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800">
<GroupIcon className="text-gray-800 size-6 dark:text-white/90" />
</div>
<div className="flex items-end justify-between mt-5">
<div>
<span className="text-sm text-gray-500 dark:text-gray-400">
Customers
</span>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
3,782
</h4>
</div>
<Badge color="success">
<ArrowUpIcon />
11.01%
</Badge>
</div>
</div>
{/* <!-- Metric Item End --> */}
{/* <!-- Metric Item Start --> */}
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800">
<BoxIconLine className="text-gray-800 dark:text-white/90" />
</div>
<div className="flex items-end justify-between mt-5">
<div>
<span className="text-sm text-gray-500 dark:text-gray-400">
Orders
</span>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
5,359
</h4>
</div>
<Badge color="error">
<ArrowDownIcon className="text-error-500" />
9.05%
</Badge>
</div>
</div>
{/* <!-- Metric Item End --> */}
</div>
);
};

View File

@@ -0,0 +1,211 @@
import {
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
} from "../ui/table";
import Badge from "../ui/badge/Badge";
import Image from "next/image";
// Define the TypeScript interface for the table rows
interface Product {
id: number; // Unique identifier for each product
name: string; // Product name
variants: string; // Number of variants (e.g., "1 Variant", "2 Variants")
category: string; // Category of the product
price: string; // Price of the product (as a string with currency symbol)
// status: string; // Status of the product
image: string; // URL or path to the product image
status: "Delivered" | "Pending" | "Canceled"; // Status of the product
}
// Define the table data using the interface
const tableData: Product[] = [
{
id: 1,
name: "MacBook Pro 13”",
variants: "2 Variants",
category: "Laptop",
price: "$2399.00",
status: "Delivered",
image: "/images/product/product-01.jpg", // Replace with actual image URL
},
{
id: 2,
name: "Apple Watch Ultra",
variants: "1 Variant",
category: "Watch",
price: "$879.00",
status: "Pending",
image: "/images/product/product-02.jpg", // Replace with actual image URL
},
{
id: 3,
name: "iPhone 15 Pro Max",
variants: "2 Variants",
category: "SmartPhone",
price: "$1869.00",
status: "Delivered",
image: "/images/product/product-03.jpg", // Replace with actual image URL
},
{
id: 4,
name: "iPad Pro 3rd Gen",
variants: "2 Variants",
category: "Electronics",
price: "$1699.00",
status: "Canceled",
image: "/images/product/product-04.jpg", // Replace with actual image URL
},
{
id: 5,
name: "AirPods Pro 2nd Gen",
variants: "1 Variant",
category: "Accessories",
price: "$240.00",
status: "Delivered",
image: "/images/product/product-05.jpg", // Replace with actual image URL
},
];
export default function RecentOrders() {
return (
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white px-4 pb-3 pt-4 dark:border-gray-800 dark:bg-white/[0.03] sm:px-6">
<div className="flex flex-col gap-2 mb-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
Recent Orders
</h3>
</div>
<div className="flex items-center gap-3">
<button className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-theme-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200">
<svg
className="stroke-current fill-white dark:fill-gray-800"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.29004 5.90393H17.7067"
stroke=""
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M17.7075 14.0961H2.29085"
stroke=""
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.0826 3.33331C13.5024 3.33331 14.6534 4.48431 14.6534 5.90414C14.6534 7.32398 13.5024 8.47498 12.0826 8.47498C10.6627 8.47498 9.51172 7.32398 9.51172 5.90415C9.51172 4.48432 10.6627 3.33331 12.0826 3.33331Z"
fill=""
stroke=""
strokeWidth="1.5"
/>
<path
d="M7.91745 11.525C6.49762 11.525 5.34662 12.676 5.34662 14.0959C5.34661 15.5157 6.49762 16.6667 7.91745 16.6667C9.33728 16.6667 10.4883 15.5157 10.4883 14.0959C10.4883 12.676 9.33728 11.525 7.91745 11.525Z"
fill=""
stroke=""
strokeWidth="1.5"
/>
</svg>
Filter
</button>
<button className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-theme-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200">
See all
</button>
</div>
</div>
<div className="max-w-full overflow-x-auto">
<Table>
{/* Table Header */}
<TableHeader className="border-gray-100 dark:border-gray-800 border-y">
<TableRow>
<TableCell
isHeader
className="py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Products
</TableCell>
<TableCell
isHeader
className="py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Category
</TableCell>
<TableCell
isHeader
className="py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Price
</TableCell>
<TableCell
isHeader
className="py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Status
</TableCell>
</TableRow>
</TableHeader>
{/* Table Body */}
<TableBody className="divide-y divide-gray-100 dark:divide-gray-800">
{tableData.map((product) => (
<TableRow key={product.id} className="">
<TableCell className="py-3">
<div className="flex items-center gap-3">
<div className="h-[50px] w-[50px] overflow-hidden rounded-md">
<Image
width={50}
height={50}
src={product.image}
className="h-[50px] w-[50px]"
alt={product.name}
/>
</div>
<div>
<p className="font-medium text-gray-800 text-theme-sm dark:text-white/90">
{product.name}
</p>
<span className="text-gray-500 text-theme-xs dark:text-gray-400">
{product.variants}
</span>
</div>
</div>
</TableCell>
<TableCell className="py-3 text-gray-500 text-theme-sm dark:text-gray-400">
{product.price}
</TableCell>
<TableCell className="py-3 text-gray-500 text-theme-sm dark:text-gray-400">
{product.category}
</TableCell>
<TableCell className="py-3 text-gray-500 text-theme-sm dark:text-gray-400">
<Badge
size="sm"
color={
product.status === "Delivered"
? "success"
: product.status === "Pending"
? "warning"
: "error"
}
>
{product.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import React from "react";
import ComponentCard from "../../common/ComponentCard";
import { Modal } from "../../ui/modal";
import Button from "../../ui/button/Button";
import { useModal } from "@/hooks/useModal";
export default function DefaultModal() {
const { isOpen, openModal, closeModal } = useModal();
const handleSave = () => {
// Handle save logic here
console.log("Saving changes...");
closeModal();
};
return (
<div>
<ComponentCard title="Default Modal">
<Button size="sm" onClick={openModal}>
Open Modal
</Button>
<Modal
isOpen={isOpen}
onClose={closeModal}
className="max-w-[600px] p-5 lg:p-10"
>
<h4 className="font-semibold text-gray-800 mb-7 text-title-sm dark:text-white/90">
Modal Heading
</h4>
<p className="text-sm leading-6 text-gray-500 dark:text-gray-400">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Pellentesque euismod est quis mauris lacinia pharetra. Sed a ligula
ac odio condimentum aliquet a nec nulla. Aliquam bibendum ex sit
amet ipsum rutrum feugiat ultrices enim quam.
</p>
<p className="mt-5 text-sm leading-6 text-gray-500 dark:text-gray-400">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Pellentesque euismod est quis mauris lacinia pharetra. Sed a ligula
ac odio.
</p>
<div className="flex items-center justify-end w-full gap-3 mt-8">
<Button size="sm" variant="outline" onClick={closeModal}>
Close
</Button>
<Button size="sm" onClick={handleSave}>
Save Changes
</Button>
</div>
</Modal>
</ComponentCard>
</div>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import React from "react";
import ComponentCard from "../../common/ComponentCard";
import Button from "../../ui/button/Button";
import { Modal } from "../../ui/modal";
import Label from "../../form/Label";
import Input from "../../form/input/InputField";
import { useModal } from "@/hooks/useModal";
export default function FormInModal() {
const { isOpen, openModal, closeModal } = useModal();
const handleSave = () => {
// Handle save logic here
console.log("Saving changes...");
closeModal();
};
return (
<ComponentCard title="Form In Modal">
<Button size="sm" onClick={openModal}>
Open Modal
</Button>
<Modal
isOpen={isOpen}
onClose={closeModal}
className="max-w-[584px] p-5 lg:p-10"
>
<form className="">
<h4 className="mb-6 text-lg font-medium text-gray-800 dark:text-white/90">
Personal Information
</h4>
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2">
<div className="col-span-1">
<Label>First Name</Label>
<Input type="text" placeholder="Emirhan" />
</div>
<div className="col-span-1">
<Label>Last Name</Label>
<Input type="text" placeholder="Boruch" />
</div>
<div className="col-span-1">
<Label>Last Name</Label>
<Input type="email" placeholder="emirhanboruch55@gmail.com" />
</div>
<div className="col-span-1">
<Label>Phone</Label>
<Input type="text" placeholder="+09 363 398 46" />
</div>
<div className="col-span-1 sm:col-span-2">
<Label>Bio</Label>
<Input type="text" placeholder="Team Manager" />
</div>
</div>
<div className="flex items-center justify-end w-full gap-3 mt-6">
<Button size="sm" variant="outline" onClick={closeModal}>
Close
</Button>
<Button size="sm" onClick={handleSave}>
Save Changes
</Button>
</div>
</form>
</Modal>
</ComponentCard>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { useModal } from "@/hooks/useModal";
import ComponentCard from "../../common/ComponentCard";
import Button from "../../ui/button/Button";
import { Modal } from "../../ui/modal";
export default function FullScreenModal() {
const {
isOpen: isFullscreenModalOpen,
openModal: openFullscreenModal,
closeModal: closeFullscreenModal,
} = useModal();
const handleSave = () => {
// Handle save logic here
console.log("Saving changes...");
closeFullscreenModal();
};
return (
<ComponentCard title="Full Screen Modal">
<Button size="sm" onClick={openFullscreenModal}>
Open Modal
</Button>
<Modal
isOpen={isFullscreenModalOpen}
onClose={closeFullscreenModal}
isFullscreen={true}
showCloseButton={true}
>
<div className="fixed top-0 left-0 flex flex-col justify-between w-full h-screen p-6 overflow-x-hidden overflow-y-auto bg-white dark:bg-gray-900 lg:p-10">
<div>
<h4 className="font-semibold text-gray-800 mb-7 text-title-sm dark:text-white/90">
Modal Heading
</h4>
<p className="text-sm leading-6 text-gray-500 dark:text-gray-400">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Pellentesque euismod est quis mauris lacinia pharetra. Sed a
ligula ac odio condimentum aliquet a nec nulla. Aliquam bibendum
ex sit amet ipsum rutrum feugiat ultrices enim quam.
</p>
<p className="mt-5 text-sm leading-6 text-gray-500 dark:text-gray-400">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Pellentesque euismod est quis mauris lacinia pharetra. Sed a
ligula ac odio condimentum aliquet a nec nulla. Aliquam bibendum
ex sit amet ipsum rutrum feugiat ultrices enim quam odio
condimentum aliquet a nec nulla pellentesque euismod est quis
mauris lacinia pharetra.
</p>
<p className="mt-5 text-sm leading-6 text-gray-500 dark:text-gray-400">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Pellentesque euismod est quis mauris lacinia pharetra.
</p>
</div>
<div className="flex items-center justify-end w-full gap-3 mt-8">
<Button size="sm" variant="outline" onClick={closeFullscreenModal}>
Close
</Button>
<Button size="sm" onClick={handleSave}>
Save Changes
</Button>
</div>
</div>
</Modal>
</ComponentCard>
);
}

View File

@@ -0,0 +1,282 @@
"use client";
import React from "react";
import ComponentCard from "../../common/ComponentCard";
import { Modal } from "../../ui/modal";
import { useModal } from "@/hooks/useModal";
export default function ModalBasedAlerts() {
const successModal = useModal();
const infoModal = useModal();
const warningModal = useModal();
const errorModal = useModal();
return (
<ComponentCard title="Modal Based Alerts">
<div className="flex flex-wrap items-center gap-3">
<button
onClick={successModal.openModal}
className="px-4 py-3 text-sm font-medium text-white rounded-lg bg-success-500 shadow-theme-xs hover:bg-success-600"
>
Success Alert
</button>
<button
onClick={infoModal.openModal}
className="px-4 py-3 text-sm font-medium text-white rounded-lg bg-blue-light-500 shadow-theme-xs hover:bg-blue-light-600"
>
Info Alert
</button>
<button
onClick={warningModal.openModal}
className="px-4 py-3 text-sm font-medium text-white rounded-lg bg-warning-500 shadow-theme-xs hover:bg-warning-600"
>
Warning Alert
</button>
<button
onClick={errorModal.openModal}
className="px-4 py-3 text-sm font-medium text-white rounded-lg bg-error-500 shadow-theme-xs hover:bg-error-600"
>
Danger Alert
</button>
</div>
{/* Success Modal */}
<Modal
isOpen={successModal.isOpen}
onClose={successModal.closeModal}
className="max-w-[600px] p-5 lg:p-10"
>
<div className="text-center">
<div className="relative flex items-center justify-center z-1 mb-7">
<svg
className="fill-success-50 dark:fill-success-500/15"
width="90"
height="90"
viewBox="0 0 90 90"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M34.364 6.85053C38.6205 -2.28351 51.3795 -2.28351 55.636 6.85053C58.0129 11.951 63.5594 14.6722 68.9556 13.3853C78.6192 11.0807 86.5743 21.2433 82.2185 30.3287C79.7862 35.402 81.1561 41.5165 85.5082 45.0122C93.3019 51.2725 90.4628 63.9451 80.7747 66.1403C75.3648 67.3661 71.5265 72.2695 71.5572 77.9156C71.6123 88.0265 60.1169 93.6664 52.3918 87.3184C48.0781 83.7737 41.9219 83.7737 37.6082 87.3184C29.8831 93.6664 18.3877 88.0266 18.4428 77.9156C18.4735 72.2695 14.6352 67.3661 9.22531 66.1403C-0.462787 63.9451 -3.30193 51.2725 4.49185 45.0122C8.84391 41.5165 10.2138 35.402 7.78151 30.3287C3.42572 21.2433 11.3808 11.0807 21.0444 13.3853C26.4406 14.6722 31.9871 11.951 34.364 6.85053Z"
fill=""
fillOpacity=""
/>
</svg>
<span className="absolute -translate-x-1/2 -translate-y-1/2 left-1/2 top-1/2">
<svg
className="fill-success-600 dark:fill-success-500"
width="38"
height="38"
viewBox="0 0 38 38"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.9375 19.0004C5.9375 11.7854 11.7864 5.93652 19.0014 5.93652C26.2164 5.93652 32.0653 11.7854 32.0653 19.0004C32.0653 26.2154 26.2164 32.0643 19.0014 32.0643C11.7864 32.0643 5.9375 26.2154 5.9375 19.0004ZM19.0014 2.93652C10.1296 2.93652 2.9375 10.1286 2.9375 19.0004C2.9375 27.8723 10.1296 35.0643 19.0014 35.0643C27.8733 35.0643 35.0653 27.8723 35.0653 19.0004C35.0653 10.1286 27.8733 2.93652 19.0014 2.93652ZM24.7855 17.0575C25.3713 16.4717 25.3713 15.522 24.7855 14.9362C24.1997 14.3504 23.25 14.3504 22.6642 14.9362L17.7177 19.8827L15.3387 17.5037C14.7529 16.9179 13.8031 16.9179 13.2173 17.5037C12.6316 18.0894 12.6316 19.0392 13.2173 19.625L16.657 23.0647C16.9383 23.346 17.3199 23.504 17.7177 23.504C18.1155 23.504 18.4971 23.346 18.7784 23.0647L24.7855 17.0575Z"
fill=""
/>
</svg>
</span>
</div>
<h4 className="mb-2 text-2xl font-semibold text-gray-800 dark:text-white/90 sm:text-title-sm">
Well Done!
</h4>
<p className="text-sm leading-6 text-gray-500 dark:text-gray-400">
Lorem ipsum dolor sit amet consectetur. Feugiat ipsum libero tempor
felis risus nisi non. Quisque eu ut tempor curabitur.
</p>
<div className="flex items-center justify-center w-full gap-3 mt-7">
<button
type="button"
className="flex justify-center w-full px-4 py-3 text-sm font-medium text-white rounded-lg bg-success-500 shadow-theme-xs hover:bg-success-600 sm:w-auto"
>
Okay, Got It
</button>
</div>
</div>
</Modal>
{/* Info Modal */}
<Modal
isOpen={infoModal.isOpen}
onClose={infoModal.closeModal}
className="max-w-[600px] p-5 lg:p-10"
>
<div className="text-center">
<div className="relative flex items-center justify-center z-1 mb-7">
<svg
className="fill-blue-light-50 dark:fill-blue-light-500/15"
width="90"
height="90"
viewBox="0 0 90 90"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M34.364 6.85053C38.6205 -2.28351 51.3795 -2.28351 55.636 6.85053C58.0129 11.951 63.5594 14.6722 68.9556 13.3853C78.6192 11.0807 86.5743 21.2433 82.2185 30.3287C79.7862 35.402 81.1561 41.5165 85.5082 45.0122C93.3019 51.2725 90.4628 63.9451 80.7747 66.1403C75.3648 67.3661 71.5265 72.2695 71.5572 77.9156C71.6123 88.0265 60.1169 93.6664 52.3918 87.3184C48.0781 83.7737 41.9219 83.7737 37.6082 87.3184C29.8831 93.6664 18.3877 88.0266 18.4428 77.9156C18.4735 72.2695 14.6352 67.3661 9.22531 66.1403C-0.462787 63.9451 -3.30193 51.2725 4.49185 45.0122C8.84391 41.5165 10.2138 35.402 7.78151 30.3287C3.42572 21.2433 11.3808 11.0807 21.0444 13.3853C26.4406 14.6722 31.9871 11.951 34.364 6.85053Z"
fill=""
fillOpacity=""
/>
</svg>
<span className="absolute -translate-x-1/2 -translate-y-1/2 left-1/2 top-1/2">
<svg
className="fill-blue-light-500 dark:fill-blue-light-500"
width="38"
height="38"
viewBox="0 0 38 38"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.85547 18.9998C5.85547 11.7396 11.7411 5.854 19.0013 5.854C26.2615 5.854 32.1471 11.7396 32.1471 18.9998C32.1471 26.2601 26.2615 32.1457 19.0013 32.1457C11.7411 32.1457 5.85547 26.2601 5.85547 18.9998ZM19.0013 2.854C10.0842 2.854 2.85547 10.0827 2.85547 18.9998C2.85547 27.9169 10.0842 35.1457 19.0013 35.1457C27.9184 35.1457 35.1471 27.9169 35.1471 18.9998C35.1471 10.0827 27.9184 2.854 19.0013 2.854ZM16.9999 11.9145C16.9999 13.0191 17.8953 13.9145 18.9999 13.9145H19.0015C20.106 13.9145 21.0015 13.0191 21.0015 11.9145C21.0015 10.81 20.106 9.91454 19.0015 9.91454H18.9999C17.8953 9.91454 16.9999 10.81 16.9999 11.9145ZM19.0014 27.8171C18.173 27.8171 17.5014 27.1455 17.5014 26.3171V17.3293C17.5014 16.5008 18.173 15.8293 19.0014 15.8293C19.8299 15.8293 20.5014 16.5008 20.5014 17.3293L20.5014 26.3171C20.5014 27.1455 19.8299 27.8171 19.0014 27.8171Z"
fill=""
/>
</svg>
</span>
</div>
<h4 className="mb-2 text-2xl font-semibold text-gray-800 dark:text-white/90 sm:text-title-sm">
Information Alert!
</h4>
<p className="text-sm leading-6 text-gray-500 dark:text-gray-400">
Lorem ipsum dolor sit amet consectetur. Feugiat ipsum libero tempor
felis risus nisi non. Quisque eu ut tempor curabitur.
</p>
<div className="flex items-center justify-center w-full gap-3 mt-7">
<button
type="button"
className="flex justify-center w-full px-4 py-3 text-sm font-medium text-white rounded-lg bg-blue-light-500 shadow-theme-xs hover:bg-blue-light-600 sm:w-auto"
>
Okay, Got It
</button>
</div>
</div>
</Modal>
{/* Warning Modal */}
<Modal
isOpen={warningModal.isOpen}
onClose={warningModal.closeModal}
className="max-w-[600px] p-5 lg:p-10"
>
<div className="text-center">
<div className="relative flex items-center justify-center z-1 mb-7">
<svg
className="fill-warning-50 dark:fill-warning-500/15"
width="90"
height="90"
viewBox="0 0 90 90"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M34.364 6.85053C38.6205 -2.28351 51.3795 -2.28351 55.636 6.85053C58.0129 11.951 63.5594 14.6722 68.9556 13.3853C78.6192 11.0807 86.5743 21.2433 82.2185 30.3287C79.7862 35.402 81.1561 41.5165 85.5082 45.0122C93.3019 51.2725 90.4628 63.9451 80.7747 66.1403C75.3648 67.3661 71.5265 72.2695 71.5572 77.9156C71.6123 88.0265 60.1169 93.6664 52.3918 87.3184C48.0781 83.7737 41.9219 83.7737 37.6082 87.3184C29.8831 93.6664 18.3877 88.0266 18.4428 77.9156C18.4735 72.2695 14.6352 67.3661 9.22531 66.1403C-0.462787 63.9451 -3.30193 51.2725 4.49185 45.0122C8.84391 41.5165 10.2138 35.402 7.78151 30.3287C3.42572 21.2433 11.3808 11.0807 21.0444 13.3853C26.4406 14.6722 31.9871 11.951 34.364 6.85053Z"
fill=""
fillOpacity=""
/>
</svg>
<span className="absolute -translate-x-1/2 -translate-y-1/2 left-1/2 top-1/2">
<svg
className="fill-warning-600 dark:fill-orange-400"
width="38"
height="38"
viewBox="0 0 38 38"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M32.1445 19.0002C32.1445 26.2604 26.2589 32.146 18.9987 32.146C11.7385 32.146 5.85287 26.2604 5.85287 19.0002C5.85287 11.7399 11.7385 5.85433 18.9987 5.85433C26.2589 5.85433 32.1445 11.7399 32.1445 19.0002ZM18.9987 35.146C27.9158 35.146 35.1445 27.9173 35.1445 19.0002C35.1445 10.0831 27.9158 2.85433 18.9987 2.85433C10.0816 2.85433 2.85287 10.0831 2.85287 19.0002C2.85287 27.9173 10.0816 35.146 18.9987 35.146ZM21.0001 26.0855C21.0001 24.9809 20.1047 24.0855 19.0001 24.0855L18.9985 24.0855C17.894 24.0855 16.9985 24.9809 16.9985 26.0855C16.9985 27.19 17.894 28.0855 18.9985 28.0855L19.0001 28.0855C20.1047 28.0855 21.0001 27.19 21.0001 26.0855ZM18.9986 10.1829C19.827 10.1829 20.4986 10.8545 20.4986 11.6829L20.4986 20.6707C20.4986 21.4992 19.827 22.1707 18.9986 22.1707C18.1701 22.1707 17.4986 21.4992 17.4986 20.6707L17.4986 11.6829C17.4986 10.8545 18.1701 10.1829 18.9986 10.1829Z"
fill=""
/>
</svg>
</span>
</div>
<h4 className="mb-2 text-2xl font-semibold text-gray-800 dark:text-white/90 sm:text-title-sm">
Warning Alert!
</h4>
<p className="text-sm leading-6 text-gray-500 dark:text-gray-400">
Lorem ipsum dolor sit amet consectetur. Feugiat ipsum libero tempor
felis risus nisi non. Quisque eu ut tempor curabitur.
</p>
<div className="flex items-center justify-center w-full gap-3 mt-7">
<button
type="button"
className="flex justify-center w-full px-4 py-3 text-sm font-medium text-white rounded-lg bg-warning-500 shadow-theme-xs hover:bg-warning-600 sm:w-auto"
>
Okay, Got It
</button>
</div>
</div>
</Modal>
{/* Error Modal */}
<Modal
isOpen={errorModal.isOpen}
onClose={errorModal.closeModal}
className="max-w-[600px] p-5 lg:p-10"
>
<div className="text-center">
<div className="relative flex items-center justify-center z-1 mb-7">
<svg
className="fill-error-50 dark:fill-error-500/15"
width="90"
height="90"
viewBox="0 0 90 90"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M34.364 6.85053C38.6205 -2.28351 51.3795 -2.28351 55.636 6.85053C58.0129 11.951 63.5594 14.6722 68.9556 13.3853C78.6192 11.0807 86.5743 21.2433 82.2185 30.3287C79.7862 35.402 81.1561 41.5165 85.5082 45.0122C93.3019 51.2725 90.4628 63.9451 80.7747 66.1403C75.3648 67.3661 71.5265 72.2695 71.5572 77.9156C71.6123 88.0265 60.1169 93.6664 52.3918 87.3184C48.0781 83.7737 41.9219 83.7737 37.6082 87.3184C29.8831 93.6664 18.3877 88.0266 18.4428 77.9156C18.4735 72.2695 14.6352 67.3661 9.22531 66.1403C-0.462787 63.9451 -3.30193 51.2725 4.49185 45.0122C8.84391 41.5165 10.2138 35.402 7.78151 30.3287C3.42572 21.2433 11.3808 11.0807 21.0444 13.3853C26.4406 14.6722 31.9871 11.951 34.364 6.85053Z"
fill=""
fillOpacity=""
/>
</svg>
<span className="absolute -translate-x-1/2 -translate-y-1/2 left-1/2 top-1/2">
<svg
className="fill-error-600 dark:fill-error-500"
width="38"
height="38"
viewBox="0 0 38 38"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.62684 11.7496C9.04105 11.1638 9.04105 10.2141 9.62684 9.6283C10.2126 9.04252 11.1624 9.04252 11.7482 9.6283L18.9985 16.8786L26.2485 9.62851C26.8343 9.04273 27.7841 9.04273 28.3699 9.62851C28.9556 10.2143 28.9556 11.164 28.3699 11.7498L21.1198 18.9999L28.3699 26.25C28.9556 26.8358 28.9556 27.7855 28.3699 28.3713C27.7841 28.9571 26.8343 28.9571 26.2485 28.3713L18.9985 21.1212L11.7482 28.3715C11.1624 28.9573 10.2126 28.9573 9.62684 28.3715C9.04105 27.7857 9.04105 26.836 9.62684 26.2502L16.8771 18.9999L9.62684 11.7496Z"
fill=""
/>
</svg>
</span>
</div>
<h4 className="mb-2 text-2xl font-semibold text-gray-800 dark:text-white/90 sm:text-title-sm">
Danger Alert!
</h4>
<p className="text-sm leading-6 text-gray-500 dark:text-gray-400">
Lorem ipsum dolor sit amet consectetur. Feugiat ipsum libero tempor
felis risus nisi non. Quisque eu ut tempor curabitur.
</p>
<div className="flex items-center justify-center w-full gap-3 mt-7">
<button
type="button"
className="flex justify-center w-full px-4 py-3 text-sm font-medium text-white rounded-lg bg-error-500 shadow-theme-xs hover:bg-error-600 sm:w-auto"
>
Okay, Got It
</button>
</div>
</div>
</Modal>
</ComponentCard>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import React from "react";
import ComponentCard from "../../common/ComponentCard";
import Button from "../../ui/button/Button";
import { Modal } from "../../ui/modal";
import { useModal } from "@/hooks/useModal";
export default function VerticallyCenteredModal() {
const { isOpen, openModal, closeModal } = useModal();
const handleSave = () => {
// Handle save logic here
console.log("Saving changes...");
closeModal();
};
return (
<ComponentCard title="Vertically Centered Modal">
<Button size="sm" onClick={openModal}>
Open Modal
</Button>
<Modal
isOpen={isOpen}
onClose={closeModal}
showCloseButton={false}
className="max-w-[507px] p-6 lg:p-10"
>
<div className="text-center">
<h4 className="mb-2 text-2xl font-semibold text-gray-800 dark:text-white/90 sm:text-title-sm">
All Done! Success Confirmed
</h4>
<p className="text-sm leading-6 text-gray-500 dark:text-gray-400">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Pellentesque euismod est quis mauris lacinia pharetra.
</p>
<div className="flex items-center justify-center w-full gap-3 mt-8">
<Button size="sm" variant="outline" onClick={closeModal}>
Close
</Button>
<Button size="sm" onClick={handleSave}>
Save Changes
</Button>
</div>
</div>
</Modal>
</ComponentCard>
);
}

View File

@@ -0,0 +1,23 @@
import React, { FC, ReactNode, FormEvent } from "react";
interface FormProps {
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
children: ReactNode;
className?: string;
}
const Form: FC<FormProps> = ({ onSubmit, children, className }) => {
return (
<form
onSubmit={(event) => {
event.preventDefault(); // Prevent default form submission
onSubmit(event);
}}
className={` ${className}`} // Default spacing between form fields
>
{children}
</form>
);
};
export default Form;

View File

@@ -0,0 +1,27 @@
import React, { FC, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
interface LabelProps {
htmlFor?: string;
children: ReactNode;
className?: string;
}
const Label: FC<LabelProps> = ({ htmlFor, children, className }) => {
return (
<label
htmlFor={htmlFor}
className={twMerge(
// Default classes that apply by default
"mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400",
// User-defined className that can override the default margin
className
)}
>
{children}
</label>
);
};
export default Label;

View File

@@ -0,0 +1,166 @@
import React, { useState } from "react";
interface Option {
value: string;
text: string;
selected: boolean;
}
interface MultiSelectProps {
label: string;
options: Option[];
defaultSelected?: string[];
onChange?: (selected: string[]) => void;
disabled?: boolean;
}
const MultiSelect: React.FC<MultiSelectProps> = ({
label,
options,
defaultSelected = [],
onChange,
disabled = false,
}) => {
const [selectedOptions, setSelectedOptions] =
useState<string[]>(defaultSelected);
const [isOpen, setIsOpen] = useState(false);
const toggleDropdown = () => {
if (disabled) return;
setIsOpen((prev) => !prev);
};
const handleSelect = (optionValue: string) => {
const newSelectedOptions = selectedOptions.includes(optionValue)
? selectedOptions.filter((value) => value !== optionValue)
: [...selectedOptions, optionValue];
setSelectedOptions(newSelectedOptions);
if (onChange) onChange(newSelectedOptions);
};
const removeOption = (index: number, value: string) => {
const newSelectedOptions = selectedOptions.filter((opt) => opt !== value);
setSelectedOptions(newSelectedOptions);
if (onChange) onChange(newSelectedOptions);
};
const selectedValuesText = selectedOptions.map(
(value) => options.find((option) => option.value === value)?.text || ""
);
return (
<div className="w-full">
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
{label}
</label>
<div className="relative z-20 inline-block w-full">
<div className="relative flex flex-col items-center">
<div onClick={toggleDropdown} className="w-full">
<div className="mb-2 flex h-11 rounded-lg border border-gray-300 py-1.5 pl-3 pr-3 shadow-theme-xs outline-hidden transition focus:border-brand-300 focus:shadow-focus-ring dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-300">
<div className="flex flex-wrap flex-auto gap-2">
{selectedValuesText.length > 0 ? (
selectedValuesText.map((text, index) => (
<div
key={index}
className="group flex items-center justify-center rounded-full border-[0.7px] border-transparent bg-gray-100 py-1 pl-2.5 pr-2 text-sm text-gray-800 hover:border-gray-200 dark:bg-gray-800 dark:text-white/90 dark:hover:border-gray-800"
>
<span className="flex-initial max-w-full">{text}</span>
<div className="flex flex-row-reverse flex-auto">
<div
onClick={() =>
removeOption(index, selectedOptions[index])
}
className="pl-2 text-gray-500 cursor-pointer group-hover:text-gray-400 dark:text-gray-400"
>
<svg
className="fill-current"
role="button"
width="14"
height="14"
viewBox="0 0 14 14"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.40717 4.46881C3.11428 4.17591 3.11428 3.70104 3.40717 3.40815C3.70006 3.11525 4.17494 3.11525 4.46783 3.40815L6.99943 5.93975L9.53095 3.40822C9.82385 3.11533 10.2987 3.11533 10.5916 3.40822C10.8845 3.70112 10.8845 4.17599 10.5916 4.46888L8.06009 7.00041L10.5916 9.53193C10.8845 9.82482 10.8845 10.2997 10.5916 10.5926C10.2987 10.8855 9.82385 10.8855 9.53095 10.5926L6.99943 8.06107L4.46783 10.5927C4.17494 10.8856 3.70006 10.8856 3.40717 10.5927C3.11428 10.2998 3.11428 9.8249 3.40717 9.53201L5.93877 7.00041L3.40717 4.46881Z"
/>
</svg>
</div>
</div>
</div>
))
) : (
<input
placeholder="Select option"
className="w-full h-full p-1 pr-2 text-sm bg-transparent border-0 outline-hidden appearance-none placeholder:text-gray-800 focus:border-0 focus:outline-hidden focus:ring-0 dark:placeholder:text-white/90"
readOnly
value="Select option"
/>
)}
</div>
<div className="flex items-center py-1 pl-1 pr-1 w-7">
<button
type="button"
onClick={toggleDropdown}
className="w-5 h-5 text-gray-700 outline-hidden cursor-pointer focus:outline-hidden dark:text-gray-400"
>
<svg
className={`stroke-current ${isOpen ? "rotate-180" : ""}`}
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.79175 7.39551L10.0001 12.6038L15.2084 7.39551"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
</div>
{isOpen && (
<div
className="absolute left-0 z-40 w-full overflow-y-auto bg-white rounded-lg shadow-sm top-full max-h-select dark:bg-gray-900"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col">
{options.map((option, index) => (
<div key={index}>
<div
className={`hover:bg-primary/5 w-full cursor-pointer rounded-t border-b border-gray-200 dark:border-gray-800`}
onClick={() => handleSelect(option.value)}
>
<div
className={`relative flex w-full items-center p-2 pl-2 ${
selectedOptions.includes(option.value)
? "bg-primary/10"
: ""
}`}
>
<div className="mx-2 leading-6 text-gray-800 dark:text-white/90">
{option.text}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default MultiSelect;

View File

@@ -0,0 +1,64 @@
import React, { useState } from "react";
interface Option {
value: string;
label: string;
}
interface SelectProps {
options: Option[];
placeholder?: string;
onChange: (value: string) => void;
className?: string;
defaultValue?: string;
}
const Select: React.FC<SelectProps> = ({
options,
placeholder = "Select an option",
onChange,
className = "",
defaultValue = "",
}) => {
// Manage the selected value
const [selectedValue, setSelectedValue] = useState<string>(defaultValue);
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
setSelectedValue(value);
onChange(value); // Trigger parent handler
};
return (
<select
className={`h-11 w-full appearance-none rounded-lg border border-gray-300 px-4 py-2.5 pr-11 text-sm shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 ${
selectedValue
? "text-gray-800 dark:text-white/90"
: "text-gray-400 dark:text-white/30"
} ${className}`}
value={selectedValue}
onChange={handleChange}
>
{/* Placeholder option */}
<option
value=""
disabled
className="text-gray-700 dark:bg-gray-900 dark:text-gray-400"
>
{placeholder}
</option>
{/* Map over options */}
{options.map((option) => (
<option
key={option.value}
value={option.value}
className="text-gray-700 dark:bg-gray-900 dark:text-gray-400"
>
{option.label}
</option>
))}
</select>
);
};
export default Select;

View File

@@ -0,0 +1,60 @@
import { useEffect } from 'react';
import flatpickr from 'flatpickr';
import 'flatpickr/dist/flatpickr.css';
import Label from './Label';
import { CalenderIcon } from '../../icons';
import Hook = flatpickr.Options.Hook;
import DateOption = flatpickr.Options.DateOption;
type PropsType = {
id: string;
mode?: "single" | "multiple" | "range" | "time";
onChange?: Hook | Hook[];
defaultDate?: DateOption;
label?: string;
placeholder?: string;
};
export default function DatePicker({
id,
mode,
onChange,
label,
defaultDate,
placeholder,
}: PropsType) {
useEffect(() => {
const flatPickr = flatpickr(`#${id}`, {
mode: mode || "single",
static: true,
monthSelectorType: "static",
dateFormat: "Y-m-d",
defaultDate,
onChange,
});
return () => {
if (!Array.isArray(flatPickr)) {
flatPickr.destroy();
}
};
}, [mode, onChange, id, defaultDate]);
return (
<div>
{label && <Label htmlFor={id}>{label}</Label>}
<div className="relative">
<input
id={id}
placeholder={placeholder}
className="h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:focus:border-brand-800"
/>
<span className="absolute text-gray-500 -translate-y-1/2 pointer-events-none right-3 top-1/2 dark:text-gray-400">
<CalenderIcon className="size-6" />
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import React, { useState } from "react";
import ComponentCard from "../../common/ComponentCard";
import Checkbox from "../input/Checkbox";
export default function CheckboxComponents() {
const [isChecked, setIsChecked] = useState(false);
const [isCheckedTwo, setIsCheckedTwo] = useState(true);
const [isCheckedDisabled, setIsCheckedDisabled] = useState(false);
return (
<ComponentCard title="Checkbox">
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<Checkbox checked={isChecked} onChange={setIsChecked} />
<span className="block text-sm font-medium text-gray-700 dark:text-gray-400">
Default
</span>
</div>
<div className="flex items-center gap-3">
<Checkbox
checked={isCheckedTwo}
onChange={setIsCheckedTwo}
label="Checked"
/>
</div>
<div className="flex items-center gap-3">
<Checkbox
checked={isCheckedDisabled}
onChange={setIsCheckedDisabled}
disabled
label="Disabled"
/>
</div>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import React, { useState } from 'react';
import ComponentCard from '../../common/ComponentCard';
import Label from '../Label';
import Input from '../input/InputField';
import Select from '../Select';
import { ChevronDownIcon, EyeCloseIcon, EyeIcon, TimeIcon } from '../../../icons';
import DatePicker from '@/components/form/date-picker';
export default function DefaultInputs() {
const [showPassword, setShowPassword] = useState(false);
const options = [
{ value: "marketing", label: "Marketing" },
{ value: "template", label: "Template" },
{ value: "development", label: "Development" },
];
const handleSelectChange = (value: string) => {
console.log("Selected value:", value);
};
return (
<ComponentCard title="Default Inputs">
<div className="space-y-6">
<div>
<Label>Input</Label>
<Input type="text" />
</div>
<div>
<Label>Input with Placeholder</Label>
<Input type="text" placeholder="info@gmail.com" />
</div>
<div>
<Label>Select Input</Label>
<div className="relative">
<Select
options={options}
placeholder="Select an option"
onChange={handleSelectChange}
className="dark:bg-dark-900"
/>
<span className="absolute text-gray-500 -translate-y-1/2 pointer-events-none right-3 top-1/2 dark:text-gray-400">
<ChevronDownIcon/>
</span>
</div>
</div>
<div>
<Label>Password Input</Label>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
/>
<button
onClick={() => setShowPassword(!showPassword)}
className="absolute z-30 -translate-y-1/2 cursor-pointer right-4 top-1/2"
>
{showPassword ? (
<EyeIcon className="fill-gray-500 dark:fill-gray-400" />
) : (
<EyeCloseIcon className="fill-gray-500 dark:fill-gray-400" />
)}
</button>
</div>
</div>
<div>
<DatePicker
id="date-picker"
label="Date Picker Input"
placeholder="Select a date"
onChange={(dates, currentDateString) => {
// Handle your logic
console.log({ dates, currentDateString });
}}
/>
</div>
<div>
<Label htmlFor="tm">Time Picker Input</Label>
<div className="relative">
<Input
type="time"
id="tm"
name="tm"
onChange={(e) => console.log(e.target.value)}
/>
<span className="absolute text-gray-500 -translate-y-1/2 pointer-events-none right-3 top-1/2 dark:text-gray-400">
<TimeIcon />
</span>
</div>
</div>
<div>
<Label htmlFor="tm">Input with Payment</Label>
<div className="relative">
<Input
type="text"
placeholder="Card number"
className="pl-[62px]"
/>
<span className="absolute left-0 top-1/2 flex h-11 w-[46px] -translate-y-1/2 items-center justify-center border-r border-gray-200 dark:border-gray-800">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="6.25" cy="10" r="5.625" fill="#E80B26" />
<circle cx="13.75" cy="10" r="5.625" fill="#F59D31" />
<path
d="M10 14.1924C11.1508 13.1625 11.875 11.6657 11.875 9.99979C11.875 8.33383 11.1508 6.8371 10 5.80713C8.84918 6.8371 8.125 8.33383 8.125 9.99979C8.125 11.6657 8.84918 13.1625 10 14.1924Z"
fill="#FC6020"
/>
</svg>
</span>
</div>
</div>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import React from "react";
import ComponentCard from "../../common/ComponentCard";
import { useDropzone } from "react-dropzone";
const DropzoneComponent: React.FC = () => {
const onDrop = (acceptedFiles: File[]) => {
console.log("Files dropped:", acceptedFiles);
// Handle file uploads here
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"image/png": [],
"image/jpeg": [],
"image/webp": [],
"image/svg+xml": [],
},
});
return (
<ComponentCard title="Dropzone">
<div className="transition border border-gray-300 border-dashed cursor-pointer dark:hover:border-brand-500 dark:border-gray-700 rounded-xl hover:border-brand-500">
<form
{...getRootProps()}
className={`dropzone rounded-xl border-dashed border-gray-300 p-7 lg:p-10
${
isDragActive
? "border-brand-500 bg-gray-100 dark:bg-gray-800"
: "border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-900"
}
`}
id="demo-upload"
>
{/* Hidden Input */}
<input {...getInputProps()} />
<div className="dz-message flex flex-col items-center m-0!">
{/* Icon Container */}
<div className="mb-[22px] flex justify-center">
<div className="flex h-[68px] w-[68px] items-center justify-center rounded-full bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-400">
<svg
className="fill-current"
width="29"
height="28"
viewBox="0 0 29 28"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.5019 3.91699C14.2852 3.91699 14.0899 4.00891 13.953 4.15589L8.57363 9.53186C8.28065 9.82466 8.2805 10.2995 8.5733 10.5925C8.8661 10.8855 9.34097 10.8857 9.63396 10.5929L13.7519 6.47752V18.667C13.7519 19.0812 14.0877 19.417 14.5019 19.417C14.9161 19.417 15.2519 19.0812 15.2519 18.667V6.48234L19.3653 10.5929C19.6583 10.8857 20.1332 10.8855 20.426 10.5925C20.7188 10.2995 20.7186 9.82463 20.4256 9.53184L15.0838 4.19378C14.9463 4.02488 14.7367 3.91699 14.5019 3.91699ZM5.91626 18.667C5.91626 18.2528 5.58047 17.917 5.16626 17.917C4.75205 17.917 4.41626 18.2528 4.41626 18.667V21.8337C4.41626 23.0763 5.42362 24.0837 6.66626 24.0837H22.3339C23.5766 24.0837 24.5839 23.0763 24.5839 21.8337V18.667C24.5839 18.2528 24.2482 17.917 23.8339 17.917C23.4197 17.917 23.0839 18.2528 23.0839 18.667V21.8337C23.0839 22.2479 22.7482 22.5837 22.3339 22.5837H6.66626C6.25205 22.5837 5.91626 22.2479 5.91626 21.8337V18.667Z"
/>
</svg>
</div>
</div>
{/* Text Content */}
<h4 className="mb-3 font-semibold text-gray-800 text-theme-xl dark:text-white/90">
{isDragActive ? "Drop Files Here" : "Drag & Drop Files Here"}
</h4>
<span className=" text-center mb-5 block w-full max-w-[290px] text-sm text-gray-700 dark:text-gray-400">
Drag and drop your PNG, JPG, WebP, SVG images here or browse
</span>
<span className="font-medium underline text-theme-sm text-brand-500">
Browse File
</span>
</div>
</form>
</div>
</ComponentCard>
);
};
export default DropzoneComponent;

View File

@@ -0,0 +1,23 @@
"use client";
import React from "react";
import ComponentCard from "../../common/ComponentCard";
import FileInput from "../input/FileInput";
import Label from "../Label";
export default function FileInputExample() {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
console.log("Selected file:", file.name);
}
};
return (
<ComponentCard title="File Input">
<div>
<Label>Upload file</Label>
<FileInput onChange={handleFileChange} className="custom-class" />
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import React from "react";
import ComponentCard from "../../common/ComponentCard";
import Label from "../Label";
import Input from "../input/InputField";
import { EnvelopeIcon } from "../../../icons";
import PhoneInput from "../group-input/PhoneInput";
export default function InputGroup() {
const countries = [
{ code: "US", label: "+1" },
{ code: "GB", label: "+44" },
{ code: "CA", label: "+1" },
{ code: "AU", label: "+61" },
];
const handlePhoneNumberChange = (phoneNumber: string) => {
console.log("Updated phone number:", phoneNumber);
};
return (
<ComponentCard title="Input Group">
<div className="space-y-6">
<div>
<Label>Email</Label>
<div className="relative">
<Input
placeholder="info@gmail.com"
type="text"
className="pl-[62px]"
/>
<span className="absolute left-0 top-1/2 -translate-y-1/2 border-r border-gray-200 px-3.5 py-3 text-gray-500 dark:border-gray-800 dark:text-gray-400">
<EnvelopeIcon />
</span>
</div>
</div>
<div>
<Label>Phone</Label>
<PhoneInput
selectPosition="start"
countries={countries}
placeholder="+1 (555) 000-0000"
onChange={handlePhoneNumberChange}
/>
</div>{" "}
<div>
<Label>Phone</Label>
<PhoneInput
selectPosition="end"
countries={countries}
placeholder="+1 (555) 000-0000"
onChange={handlePhoneNumberChange}
/>
</div>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import React, { useState } from "react";
import ComponentCard from "../../common/ComponentCard";
import Input from "../input/InputField";
import Label from "../Label";
export default function InputStates() {
const [email, setEmail] = useState("");
const [error, setError] = useState(false);
// Simulate a validation check
const validateEmail = (value: string) => {
const isValidEmail =
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value);
setError(!isValidEmail);
return isValidEmail;
};
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmail(value);
validateEmail(value);
};
return (
<ComponentCard
title="Input States"
desc="Validation styles for error, success and disabled states on form controls."
>
<div className="space-y-5 sm:space-y-6">
{/* Error Input */}
<div>
<Label>Email</Label>
<Input
type="email"
defaultValue={email}
error={error}
onChange={handleEmailChange}
placeholder="Enter your email"
hint={error ? "This is an invalid email address." : ""}
/>
</div>
{/* Success Input */}
<div>
<Label>Email</Label>
<Input
type="email"
defaultValue={email}
success={!error}
onChange={handleEmailChange}
placeholder="Enter your email"
hint={!error ? "Valid email!" : ""}
/>
</div>
{/* Disabled Input */}
<div>
<Label>Email</Label>
<Input
type="text"
defaultValue="disabled@example.com"
disabled={true}
placeholder="Disabled email"
hint="This field is disabled."
/>
</div>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import React, { useState } from "react";
import ComponentCard from "../../common/ComponentCard";
import Radio from "../input/Radio";
export default function RadioButtons() {
const [selectedValue, setSelectedValue] = useState<string>("option2");
const handleRadioChange = (value: string) => {
setSelectedValue(value);
};
return (
<ComponentCard title="Radio Buttons">
<div className="flex flex-wrap items-center gap-8">
<Radio
id="radio1"
name="group1"
value="option1"
checked={selectedValue === "option1"}
onChange={handleRadioChange}
label="Default"
/>
<Radio
id="radio2"
name="group1"
value="option2"
checked={selectedValue === "option2"}
onChange={handleRadioChange}
label="Selected"
/>
<Radio
id="radio3"
name="group1"
value="option3"
checked={selectedValue === "option3"}
onChange={handleRadioChange}
label="Disabled"
disabled={true}
/>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import React, { useState } from "react";
import ComponentCard from "../../common/ComponentCard";
import Label from "../Label";
import Select from "../Select";
import MultiSelect from "../MultiSelect";
import { ChevronDownIcon } from "@/icons";
export default function SelectInputs() {
const options = [
{ value: "marketing", label: "Marketing" },
{ value: "template", label: "Template" },
{ value: "development", label: "Development" },
];
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const handleSelectChange = (value: string) => {
console.log("Selected value:", value);
};
const multiOptions = [
{ value: "1", text: "Option 1", selected: false },
{ value: "2", text: "Option 2", selected: false },
{ value: "3", text: "Option 3", selected: false },
{ value: "4", text: "Option 4", selected: false },
{ value: "5", text: "Option 5", selected: false },
];
return (
<ComponentCard title="Select Inputs">
<div className="space-y-6">
<div>
<Label>Select Input</Label>
<div className="relative">
<Select
options={options}
placeholder="Select Option"
onChange={handleSelectChange}
className="dark:bg-dark-900"
/>
<span className="absolute text-gray-500 -translate-y-1/2 pointer-events-none right-3 top-1/2 dark:text-gray-400">
<ChevronDownIcon/>
</span>
</div>
</div>
<div className="relative">
<MultiSelect
label="Multiple Select Options"
options={multiOptions}
defaultSelected={["1", "3"]}
onChange={(values) => setSelectedValues(values)}
/>
<p className="sr-only">
Selected Values: {selectedValues.join(", ")}
</p>
</div>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import React, { useState } from "react";
import ComponentCard from "../../common/ComponentCard";
import TextArea from "../input/TextArea";
import Label from "../Label";
export default function TextAreaInput() {
const [message, setMessage] = useState("");
const [messageTwo, setMessageTwo] = useState("");
return (
<ComponentCard title="Textarea input field">
<div className="space-y-6">
{/* Default TextArea */}
<div>
<Label>Description</Label>
<TextArea
value={message}
onChange={(value) => setMessage(value)}
rows={6}
/>
</div>
{/* Disabled TextArea */}
<div>
<Label>Description</Label>
<TextArea rows={6} disabled />
</div>
{/* Error TextArea */}
<div>
<Label>Description</Label>
<TextArea
rows={6}
value={messageTwo}
error
onChange={(value) => setMessageTwo(value)}
hint="Please enter a valid message."
/>
</div>
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import React from "react";
import ComponentCard from "../../common/ComponentCard";
import Switch from "../switch/Switch";
export default function ToggleSwitch() {
const handleSwitchChange = (checked: boolean) => {
console.log("Switch is now:", checked ? "ON" : "OFF");
};
return (
<ComponentCard title="Toggle switch input">
<div className="flex gap-4">
<Switch
label="Default"
defaultChecked={true}
onChange={handleSwitchChange}
/>
<Switch
label="Checked"
defaultChecked={true}
onChange={handleSwitchChange}
/>
<Switch label="Disabled" disabled={true} />
</div>{" "}
<div className="flex gap-4">
<Switch
label="Default"
defaultChecked={true}
onChange={handleSwitchChange}
color="gray"
/>
<Switch
label="Checked"
defaultChecked={true}
onChange={handleSwitchChange}
color="gray"
/>
<Switch label="Disabled" disabled={true} color="gray" />
</div>
</ComponentCard>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import React, { useState } from "react";
interface CountryCode {
code: string;
label: string;
}
interface PhoneInputProps {
countries: CountryCode[];
placeholder?: string;
onChange?: (phoneNumber: string) => void;
selectPosition?: "start" | "end"; // New prop for dropdown position
}
const PhoneInput: React.FC<PhoneInputProps> = ({
countries,
placeholder = "+1 (555) 000-0000",
onChange,
selectPosition = "start", // Default position is 'start'
}) => {
const [selectedCountry, setSelectedCountry] = useState<string>("US");
const [phoneNumber, setPhoneNumber] = useState<string>("+1");
const countryCodes: Record<string, string> = countries.reduce(
(acc, { code, label }) => ({ ...acc, [code]: label }),
{}
);
const handleCountryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newCountry = e.target.value;
setSelectedCountry(newCountry);
setPhoneNumber(countryCodes[newCountry]);
if (onChange) {
onChange(countryCodes[newCountry]);
}
};
const handlePhoneNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newPhoneNumber = e.target.value;
setPhoneNumber(newPhoneNumber);
if (onChange) {
onChange(newPhoneNumber);
}
};
return (
<div className="relative flex">
{/* Dropdown position: Start */}
{selectPosition === "start" && (
<div className="absolute">
<select
value={selectedCountry}
onChange={handleCountryChange}
className="appearance-none bg-none rounded-l-lg border-0 border-r border-gray-200 bg-transparent py-3 pl-3.5 pr-8 leading-tight text-gray-700 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-gray-400"
>
{countries.map((country) => (
<option
key={country.code}
value={country.code}
className="text-gray-700 dark:bg-gray-900 dark:text-gray-400"
>
{country.code}
</option>
))}
</select>
<div className="absolute inset-y-0 flex items-center text-gray-700 pointer-events-none bg-none right-3 dark:text-gray-400">
<svg
className="stroke-current"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.79175 7.396L10.0001 12.6043L15.2084 7.396"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
)}
{/* Input field */}
<input
type="tel"
value={phoneNumber}
onChange={handlePhoneNumberChange}
placeholder={placeholder}
className={`dark:bg-dark-900 h-11 w-full ${
selectPosition === "start" ? "pl-[84px]" : "pr-[84px]"
} rounded-lg border border-gray-300 bg-transparent py-3 px-4 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800`}
/>
{/* Dropdown position: End */}
{selectPosition === "end" && (
<div className="absolute right-0">
<select
value={selectedCountry}
onChange={handleCountryChange}
className="appearance-none bg-none rounded-r-lg border-0 border-l border-gray-200 bg-transparent py-3 pl-3.5 pr-8 leading-tight text-gray-700 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-gray-400"
>
{countries.map((country) => (
<option
key={country.code}
value={country.code}
className="text-gray-700 dark:bg-gray-900 dark:text-gray-400"
>
{country.code}
</option>
))}
</select>
<div className="absolute inset-y-0 flex items-center text-gray-700 pointer-events-none right-3 dark:text-gray-400">
<svg
className="stroke-current"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.79175 7.396L10.0001 12.6043L15.2084 7.396"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
)}
</div>
);
};
export default PhoneInput;

View File

@@ -0,0 +1,82 @@
import type React from "react";
interface CheckboxProps {
label?: string;
checked: boolean;
className?: string;
id?: string;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
const Checkbox: React.FC<CheckboxProps> = ({
label,
checked,
id,
onChange,
className = "",
disabled = false,
}) => {
return (
<label
className={`flex items-center space-x-3 group cursor-pointer ${
disabled ? "cursor-not-allowed opacity-60" : ""
}`}
>
<div className="relative w-5 h-5">
<input
id={id}
type="checkbox"
className={`w-5 h-5 appearance-none cursor-pointer dark:border-gray-700 border border-gray-300 checked:border-transparent rounded-md checked:bg-brand-500 disabled:opacity-60
${className}`}
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
{checked && (
<svg
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
>
<path
d="M11.6666 3.5L5.24992 9.91667L2.33325 7"
stroke="white"
strokeWidth="1.94437"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
{disabled && (
<svg
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
>
<path
d="M11.6666 3.5L5.24992 9.91667L2.33325 7"
stroke="#E4E7EC"
strokeWidth="2.33333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
{label && (
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
{label}
</span>
)}
</label>
);
};
export default Checkbox;

View File

@@ -0,0 +1,18 @@
import React, { FC } from "react";
interface FileInputProps {
className?: string;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
const FileInput: FC<FileInputProps> = ({ className, onChange }) => {
return (
<input
type="file"
className={`focus:border-ring-brand-300 h-11 w-full overflow-hidden rounded-lg border border-gray-300 bg-transparent text-sm text-gray-500 shadow-theme-xs transition-colors file:mr-5 file:border-collapse file:cursor-pointer file:rounded-l-lg file:border-0 file:border-r file:border-solid file:border-gray-200 file:bg-gray-50 file:py-3 file:pl-3.5 file:pr-3 file:text-sm file:text-gray-700 placeholder:text-gray-400 hover:file:bg-gray-100 focus:outline-hidden focus:file:ring-brand-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400 dark:text-white/90 dark:file:border-gray-800 dark:file:bg-white/[0.03] dark:file:text-gray-400 dark:placeholder:text-gray-400 ${className}`}
onChange={onChange}
/>
);
};
export default FileInput;

View File

@@ -0,0 +1,96 @@
import React, { FC } from "react";
interface InputProps {
type?: "text" | "number" | "email" | "password" | "date" | "time" | string;
id?: string;
name?: string;
placeholder?: string;
defaultValue?: string | number;
value?: string | number;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
className?: string;
min?: string;
max?: string;
step?: number;
disabled?: boolean;
required?: boolean;
success?: boolean;
error?: boolean;
hint?: string; // Optional hint text
maxLength?: number;
autoFocus?: boolean;
}
const Input: FC<InputProps> = ({
type = "text",
id,
name,
placeholder,
defaultValue,
value,
onChange,
className = "",
min,
max,
step,
disabled = false,
required = false,
success = false,
error = false,
hint,
maxLength,
autoFocus,
}) => {
// Determine input styles based on state (disabled, success, error)
let inputClasses = `h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 ${className}`;
// Add styles for the different states
if (disabled) {
inputClasses += ` text-gray-500 border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700`;
} else if (error) {
inputClasses += ` text-error-800 border-error-500 focus:ring-3 focus:ring-error-500/10 dark:text-error-400 dark:border-error-500`;
} else if (success) {
inputClasses += ` text-success-500 border-success-400 focus:ring-success-500/10 focus:border-success-300 dark:text-success-400 dark:border-success-500`;
} else {
inputClasses += ` bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-brand-800`;
}
return (
<div className="relative">
<input
type={type}
id={id}
name={name}
placeholder={placeholder}
defaultValue={defaultValue}
value={value}
onChange={onChange}
min={min}
max={max}
step={step}
disabled={disabled}
required={required}
maxLength={maxLength}
autoFocus={autoFocus}
className={inputClasses}
/>
{/* Optional Hint Text */}
{hint && (
<p
className={`mt-1.5 text-xs ${
error
? "text-error-500"
: success
? "text-success-500"
: "text-gray-500"
}`}
>
{hint}
</p>
)}
</div>
);
};
export default Input;

View File

@@ -0,0 +1,65 @@
import React from "react";
interface RadioProps {
id: string; // Unique ID for the radio button
name: string; // Radio group name
value: string; // Value of the radio button
checked: boolean; // Whether the radio button is checked
label: string; // Label for the radio button
onChange: (value: string) => void; // Handler for value change
className?: string; // Optional additional classes
disabled?: boolean; // Optional disabled state for the radio button
}
const Radio: React.FC<RadioProps> = ({
id,
name,
value,
checked,
label,
onChange,
className = "",
disabled = false,
}) => {
return (
<label
htmlFor={id}
className={`relative flex cursor-pointer select-none items-center gap-3 text-sm font-medium ${
disabled
? "text-gray-300 dark:text-gray-600 cursor-not-allowed"
: "text-gray-700 dark:text-gray-400"
} ${className}`}
>
<input
id={id}
name={name}
type="radio"
value={value}
checked={checked}
onChange={() => !disabled && onChange(value)} // Prevent onChange when disabled
className="sr-only"
disabled={disabled} // Disable input
/>
<span
className={`flex h-5 w-5 items-center justify-center rounded-full border-[1.25px] ${
checked
? "border-brand-500 bg-brand-500"
: "bg-transparent border-gray-300 dark:border-gray-700"
} ${
disabled
? "bg-gray-100 dark:bg-gray-700 border-gray-200 dark:border-gray-700"
: ""
}`}
>
<span
className={`h-2 w-2 rounded-full bg-white ${
checked ? "block" : "hidden"
}`}
></span>
</span>
{label}
</label>
);
};
export default Radio;

View File

@@ -0,0 +1,59 @@
import React from "react";
interface RadioProps {
id: string; // Unique ID for the radio button
name: string; // Group name for the radio button
value: string; // Value of the radio button
checked: boolean; // Whether the radio button is checked
label: string; // Label text for the radio button
onChange: (value: string) => void; // Handler for when the radio button is toggled
className?: string; // Optional custom classes for styling
}
const RadioSm: React.FC<RadioProps> = ({
id,
name,
value,
checked,
label,
onChange,
className = "",
}) => {
return (
<label
htmlFor={id}
className={`flex cursor-pointer select-none items-center text-sm text-gray-700 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors ${className}`}
>
<span className="relative">
{/* Hidden Input */}
<input
type="radio"
id={id}
name={name}
value={value}
checked={checked}
onChange={() => onChange(value)}
className="sr-only"
/>
{/* Styled Radio Circle */}
<span
className={`mr-2 flex h-4 w-4 items-center justify-center rounded-full border ${
checked
? "border-brand-500 bg-brand-500"
: "bg-transparent border-gray-300 dark:border-gray-700"
}`}
>
{/* Inner Dot */}
<span
className={`h-1.5 w-1.5 rounded-full ${
checked ? "bg-white" : "bg-white dark:bg-[#1e2636]"
}`}
></span>
</span>
</span>
{label}
</label>
);
};
export default RadioSm;

View File

@@ -0,0 +1,63 @@
import React from "react";
interface TextareaProps {
placeholder?: string; // Placeholder text
rows?: number; // Number of rows
value?: string; // Current value
onChange?: (value: string) => void; // Change handler
className?: string; // Additional CSS classes
disabled?: boolean; // Disabled state
error?: boolean; // Error state
hint?: string; // Hint text to display
}
const TextArea: React.FC<TextareaProps> = ({
placeholder = "Enter your message", // Default placeholder
rows = 3, // Default number of rows
value = "", // Default value
onChange, // Callback for changes
className = "", // Additional custom styles
disabled = false, // Disabled state
error = false, // Error state
hint = "", // Default hint text
}) => {
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (onChange) {
onChange(e.target.value);
}
};
let textareaClasses = `w-full rounded-lg border px-4 py-2.5 text-sm shadow-theme-xs focus:outline-hidden ${className}`;
if (disabled) {
textareaClasses += ` bg-gray-100 opacity-50 text-gray-500 border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700`;
} else if (error) {
textareaClasses += ` bg-transparent text-gray-400 border-gray-300 focus:border-error-300 focus:ring-3 focus:ring-error-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-error-800`;
} else {
textareaClasses += ` bg-transparent text-gray-400 border-gray-300 focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-brand-800`;
}
return (
<div className="relative">
<textarea
placeholder={placeholder}
rows={rows}
value={value}
onChange={handleChange}
disabled={disabled}
className={textareaClasses}
/>
{hint && (
<p
className={`mt-2 text-sm ${
error ? "text-error-500" : "text-gray-500 dark:text-gray-400"
}`}
>
{hint}
</p>
)}
</div>
);
};
export default TextArea;

View File

@@ -0,0 +1,77 @@
"use client";
import React, { useState, useEffect } from "react";
interface SwitchProps {
label: string;
defaultChecked?: boolean;
disabled?: boolean;
onChange?: (checked: boolean) => void;
color?: "blue" | "gray"; // Added prop to toggle color theme
}
const Switch: React.FC<SwitchProps> = ({
label,
defaultChecked = false,
disabled = false,
onChange,
color = "blue", // Default to blue color
}) => {
const [isChecked, setIsChecked] = useState(defaultChecked);
useEffect(() => {
setIsChecked(defaultChecked);
}, [defaultChecked]);
const handleToggle = () => {
if (disabled) return;
const newCheckedState = !isChecked;
setIsChecked(newCheckedState);
if (onChange) {
onChange(newCheckedState);
}
};
const switchColors =
color === "blue"
? {
background: isChecked
? "bg-brand-500 "
: "bg-gray-200 dark:bg-white/10", // Blue version
knob: isChecked
? "translate-x-full bg-white"
: "translate-x-0 bg-white",
}
: {
background: isChecked
? "bg-gray-800 dark:bg-white/10"
: "bg-gray-200 dark:bg-white/10", // Gray version
knob: isChecked
? "translate-x-full bg-white"
: "translate-x-0 bg-white",
};
return (
<label
className={`flex cursor-pointer select-none items-center gap-3 text-sm font-medium ${
disabled ? "text-gray-400" : "text-gray-700 dark:text-gray-400"
}`}
onClick={handleToggle} // Toggle when the label itself is clicked
>
<div className="relative">
<div
className={`block transition duration-150 ease-linear h-6 w-11 rounded-full ${
disabled
? "bg-gray-100 pointer-events-none dark:bg-gray-800"
: switchColors.background
}`}
></div>
<div
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full shadow-theme-sm duration-150 ease-linear transform ${switchColors.knob}`}
></div>
</div>
{label}
</label>
);
};
export default Switch;

View File

@@ -0,0 +1,45 @@
"use client";
import { useI18n } from "@/components/providers/I18nProvider";
import { useAuth } from "@/hooks/useAuth";
import axios from "@/lib/axios";
import { useTranslations } from "next-intl";
import { LanguagesIcon } from "lucide-react";
import useSWR from "swr";
export const LanguageSwitcher: React.FC = () => {
const { locale, setLocale } = useI18n();
const { user, mutate } = useAuth();
const toggleLanguage = async () => {
const newLocale = locale === "en" ? "id" : "en";
try {
// Update locally for instant feedback
setLocale(newLocale);
// Update backend for persistence if user is logged in
if (user) {
await axios.patch("/api/profile", {
language: newLocale,
});
// Refresh user data
mutate();
}
} catch (error) {
console.error("Failed to update language preference", error);
}
};
return (
<button
onClick={toggleLanguage}
className="relative flex h-10 w-10 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-500 hover:text-brand-500 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-400 dark:hover:text-white"
title={locale === "en" ? "Ganti ke Bahasa Indonesia" : "Switch to English"}
>
<LanguagesIcon size={20} />
<span className="absolute -bottom-1 -right-1 flex h-4 b-4 items-center justify-center rounded-full bg-brand-500 px-1 text-[8px] font-bold text-white uppercase">
{locale}
</span>
</button>
);
};

View File

@@ -0,0 +1,276 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import Link from "next/link";
import Image from "next/image";
import axios from "@/lib/axios";
import { Bell, Check, Info, AlertTriangle, X } from "lucide-react";
import { Dropdown } from "../ui/dropdown/Dropdown";
import { DropdownItem } from "../ui/dropdown/DropdownItem";
import { useRouter } from "next/navigation";
import { getUserAvatar } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { useAuth } from "@/hooks/useAuth";
import echo from "@/lib/echo";
interface Notification {
id: string;
type: string;
data: {
message: string;
url?: string;
certificate_id?: number | string;
common_name?: string;
days_remaining?: number;
title?: string;
sender_name?: string;
sender_avatar?: string;
icon?: string;
type?: string;
};
read_at: string | null;
created_at: string;
}
export default function NotificationDropdown() {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [notifying, setNotifying] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(false);
const t = useTranslations("Notifications");
const { user } = useAuth();
useEffect(() => {
fetchNotifications();
// Fallback polling
const interval = setInterval(fetchNotifications, 60000);
// Real-time updates via Laravel Echo
if (user?.id && echo) {
const channel = echo.private(`App.Models.User.${user.id}`);
channel
.notification((notification: any) => {
fetchNotifications();
})
.listen('.Illuminate\\Notifications\\Events\\BroadcastNotificationCreated', (e: any) => {
fetchNotifications();
});
}
return () => {
clearInterval(interval);
if (user?.id && echo) {
echo.leave(`App.Models.User.${user.id}`);
}
};
}, [user?.id]);
const fetchNotifications = async () => {
try {
const response = await axios.get("/api/notifications");
const fetchedNotifications = response.data.data || [];
setNotifications(fetchedNotifications);
const unreadCount = fetchedNotifications.filter((n: Notification) => !n.read_at).length;
setNotifying(unreadCount > 0);
} catch (error) {
console.error("Failed to fetch notifications:", error);
}
};
const toggleDropdown = () => {
setIsOpen(!isOpen);
if (!isOpen) {
// Refetch when opening to be fresh
fetchNotifications();
}
};
const closeDropdown = () => setIsOpen(false);
const markAsRead = async (id: string) => {
try {
await axios.patch(`/api/notifications/${id}/read`);
setNotifications(notifications.map(n => n.id === id ? { ...n, read_at: new Date().toISOString() } : n));
const unreadCount = notifications.filter(n => !n.read_at && n.id !== id).length;
setNotifying(unreadCount > 0);
} catch (error) {
console.error("Failed to mark notification as read:", error);
}
};
const handleNotificationClick = async (notification: Notification) => {
if (!notification.read_at) {
await markAsRead(notification.id);
}
closeDropdown();
// Support navigation if a URL is provided in the data
const url = notification.data.url;
if (url) {
router.push(url);
}
};
const markAllAsRead = async (e: React.MouseEvent) => {
e.preventDefault();
try {
await axios.post('/api/notifications/mark-all-read');
setNotifications(notifications.map(n => ({ ...n, read_at: new Date().toISOString() })));
setNotifying(false);
} catch (error) {
console.error("Failed to mark all as read:", error);
}
};
const getRelativeTime = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) return t("just_now");
if (diffInSeconds < 3600) return t("minutes_ago", { n: Math.floor(diffInSeconds / 60) });
if (diffInSeconds < 86400) return t("hours_ago", { n: Math.floor(diffInSeconds / 3600) });
return date.toLocaleDateString();
};
const getNotificationIcon = (notification: Notification) => {
const iconName = notification.data.icon;
const type = notification.type;
if (iconName === 'check-circle') return <Check size={18} className="text-green-500" />;
if (iconName === 'trash-2' || iconName === 'alert-triangle') return <AlertTriangle size={18} className="text-orange-500" />;
if (iconName === 'support-ticket') return <Bell size={18} className="text-brand-500" />;
if (iconName === 'inbox') return <Bell size={18} className="text-blue-500" />;
// Fallback logic
if (type.includes('Expiry') || type.includes('Urgent')) {
return <AlertTriangle size={18} className="text-orange-500" />;
}
return <Info size={18} className="text-blue-500" />;
};
return (
<div className="relative">
<button
className="relative dropdown-toggle flex items-center justify-center text-gray-500 transition-colors bg-white border border-gray-200 rounded-full hover:text-gray-700 h-11 w-11 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
onClick={toggleDropdown}
>
<span
className={`absolute right-0 top-0.5 z-10 h-2 w-2 rounded-full bg-brand-500 ${
!notifying ? "hidden" : "flex"
}`}
>
<span className="absolute inline-flex w-full h-full bg-brand-500 rounded-full opacity-75 animate-ping"></span>
</span>
<Bell size={20} className="fill-current" />
</button>
<Dropdown
isOpen={isOpen}
onClose={closeDropdown}
className="absolute right-0 mt-[17px] flex h-[480px] w-[90vw] max-w-[360px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark sm:w-[361px] sm:right-0 fixed sm:absolute top-16 sm:top-auto left-4 sm:left-auto right-4 sm:right-0"
>
<div className="flex items-center justify-between pb-3 mb-3 border-b border-gray-100 dark:border-gray-700">
<h5 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
{t("title")}
</h5>
<button
onClick={closeDropdown}
className="text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<X size={20} />
</button>
</div>
<ul className="flex flex-col h-auto overflow-y-auto custom-scrollbar flex-grow">
{notifications.length === 0 ? (
<li className="flex flex-col items-center justify-center h-full py-10 text-center">
<div className="flex items-center justify-center w-12 h-12 mb-3 bg-gray-100 rounded-full dark:bg-gray-800">
<Bell size={24} className="text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">{t("none")}</p>
<p className="text-xs text-gray-400 dark:text-gray-500">{t("none_subtitle")}</p>
</li>
) : (
notifications.map((notification) => {
const isTicket = notification.type.includes('Ticket');
const hasSender = notification.data.sender_name || notification.data.sender_avatar;
return (
<li key={notification.id}>
<DropdownItem
onItemClick={() => handleNotificationClick(notification)}
className={`flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5 transition-colors ${!notification.read_at ? 'bg-brand-50/30 dark:bg-brand-500/5' : ''}`}
>
<div className="shrink-0">
{isTicket && hasSender ? (
<div className="relative w-10 h-10 overflow-hidden rounded-full border border-gray-200 dark:border-gray-700">
<Image
src={getUserAvatar({
avatar: notification.data.sender_avatar,
name: notification.data.sender_name
})}
alt={notification.data.sender_name || "User"}
fill
className="object-cover"
unoptimized
/>
</div>
) : (
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${!notification.read_at ? 'bg-white dark:bg-gray-900 border border-brand-100 dark:border-brand-900/50' : 'bg-gray-50 dark:bg-gray-800/50'}`}>
{getNotificationIcon(notification)}
</div>
)}
</div>
<div className="flex flex-col overflow-hidden">
<span className="block text-sm">
<span className={`text-gray-800 dark:text-white/90 leading-snug ${!notification.read_at ? 'font-semibold' : 'font-normal'}`}>
{notification.data.title || t("system_update")}
</span>
</span>
{notification.data.message && (
<span className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">
{notification.data.message}
</span>
)}
<span className="flex items-center gap-2 mt-1.5 text-gray-400 text-[10px] uppercase font-medium tracking-wider">
<span>{notification.data.type?.split('\\').pop()?.replace('Notification', '') || 'System'}</span>
<span className="w-1 h-1 bg-gray-300 dark:bg-gray-600 rounded-full"></span>
<span>{getRelativeTime(notification.created_at)}</span>
</span>
</div>
</DropdownItem>
</li>
);
})
)}
</ul>
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">
{notifications.length > 0 && (
<button
onClick={markAllAsRead}
className="flex-1 px-4 py-2 text-xs font-medium text-center text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 transition-colors"
>
{t("mark_all_read")}
</button>
)}
<Link
href="/dashboard/notifications"
className="flex-1 px-4 py-2 text-xs font-medium text-center text-white bg-brand-500 border border-transparent rounded-lg hover:bg-brand-600 transition-colors"
onClick={closeDropdown}
>
{t("view_all")}
</Link>
</div>
</Dropdown>
</div>
);
}

View File

@@ -0,0 +1,206 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import React, { useState } from "react";
import { Dropdown } from "../ui/dropdown/Dropdown";
import { DropdownItem } from "../ui/dropdown/DropdownItem";
import { useAuth } from "@/hooks/useAuth";
import { getUserAvatar } from "@/lib/utils";
import { useTranslations } from "next-intl";
export default function UserDropdown() {
const [isOpen, setIsOpen] = useState(false);
const { user, logout } = useAuth();
const t = useTranslations("Common");
function toggleDropdown(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
e.stopPropagation();
setIsOpen((prev) => !prev);
}
function closeDropdown() {
setIsOpen(false);
}
return (
<div className="relative">
<button
onClick={toggleDropdown}
className="flex items-center text-gray-700 dark:text-gray-400 dropdown-toggle"
>
<span className="mr-3 overflow-hidden rounded-full h-11 w-11">
<Image
width={44}
height={44}
src={getUserAvatar(user)}
alt="User"
unoptimized={true}
/>
</span>
<span className="hidden text-left lg:block mr-1">
<span className="block font-medium text-theme-sm text-gray-700 dark:text-gray-400">
{user?.first_name ? `${user?.first_name} ${user?.last_name || ''}` : t("guest_user")}
</span>
</span>
<svg
className={`stroke-gray-500 dark:stroke-gray-400 transition-transform duration-200 ${
isOpen ? "rotate-180" : ""
}`}
width="18"
height="20"
viewBox="0 0 18 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.3125 8.65625L9 13.3437L13.6875 8.65625"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<Dropdown
isOpen={isOpen}
onClose={closeDropdown}
className="absolute right-0 mt-[17px] flex w-[260px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark"
>
<div>
<span className="block font-medium text-gray-700 text-theme-sm dark:text-gray-400">
{user?.first_name ? `${user?.first_name} ${user?.last_name || ''}` : t("guest_user")}
</span>
<span className="mt-0.5 block text-theme-xs text-gray-500 dark:text-gray-400">
{user?.email || t("no_email")}
</span>
</div>
<ul className="flex flex-col gap-1 pt-4 pb-3 border-b border-gray-200 dark:border-gray-800">
<li>
<DropdownItem
onItemClick={closeDropdown}
tag="a"
href="/dashboard/profile"
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
<svg
className="fill-gray-500 group-hover:fill-gray-700 dark:fill-gray-400 dark:group-hover:fill-gray-300"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 3.5C7.30558 3.5 3.5 7.30558 3.5 12C3.5 14.1526 4.3002 16.1184 5.61936 17.616C6.17279 15.3096 8.24852 13.5955 10.7246 13.5955H13.2746C15.7509 13.5955 17.8268 15.31 18.38 17.6167C19.6996 16.119 20.5 14.153 20.5 12C20.5 7.30558 16.6944 3.5 12 3.5ZM17.0246 18.8566V18.8455C17.0246 16.7744 15.3457 15.0955 13.2746 15.0955H10.7246C8.65354 15.0955 6.97461 16.7744 6.97461 18.8455V18.856C8.38223 19.8895 10.1198 20.5 12 20.5C13.8798 20.5 15.6171 19.8898 17.0246 18.8566ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM11.9991 7.25C10.8847 7.25 9.98126 8.15342 9.98126 9.26784C9.98126 10.3823 10.8847 11.2857 11.9991 11.2857C13.1135 11.2857 14.0169 10.3823 14.0169 9.26784C14.0169 8.15342 13.1135 7.25 11.9991 7.25ZM8.48126 9.26784C8.48126 7.32499 10.0563 5.75 11.9991 5.75C13.9419 5.75 15.5169 7.32499 15.5169 9.26784C15.5169 11.2107 13.9419 12.7857 11.9991 12.7857C10.0563 12.7857 8.48126 11.2107 8.48126 9.26784Z"
fill=""
/>
</svg>
{t("edit_profile")}
</DropdownItem>
</li>
<li>
<DropdownItem
onItemClick={closeDropdown}
tag="a"
href="/dashboard/settings"
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
<svg
className="fill-gray-500 group-hover:fill-gray-700 dark:fill-gray-400 dark:group-hover:fill-gray-300"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.4858 3.5L13.5182 3.5C13.9233 3.5 14.2518 3.82851 14.2518 4.23377C14.2518 5.9529 16.1129 7.02795 17.602 6.1682C17.9528 5.96567 18.4014 6.08586 18.6039 6.43667L20.1203 9.0631C20.3229 9.41407 20.2027 9.86286 19.8517 10.0655C18.3625 10.9253 18.3625 13.0747 19.8517 13.9345C20.2026 14.1372 20.3229 14.5859 20.1203 14.9369L18.6039 17.5634C18.4013 17.9142 17.9528 18.0344 17.602 17.8318C16.1129 16.9721 14.2518 18.0471 14.2518 19.7663C14.2518 20.1715 13.9233 20.5 13.5182 20.5H10.4858C10.0804 20.5 9.75182 20.1714 9.75182 19.766C9.75182 18.0461 7.88983 16.9717 6.40067 17.8314C6.04945 18.0342 5.60037 17.9139 5.39767 17.5628L3.88167 14.937C3.67903 14.586 3.79928 14.1372 4.15026 13.9346C5.63949 13.0748 5.63946 10.9253 4.15025 10.0655C3.79926 9.86282 3.67901 9.41401 3.88165 9.06303L5.39764 6.43725C5.60034 6.08617 6.04943 5.96581 6.40065 6.16858C7.88982 7.02836 9.75182 5.9539 9.75182 4.23399C9.75182 3.82862 10.0804 3.5 10.4858 3.5ZM13.5182 2L10.4858 2C9.25201 2 8.25182 3.00019 8.25182 4.23399C8.25182 4.79884 7.64013 5.15215 7.15065 4.86955C6.08213 4.25263 4.71559 4.61859 4.0986 5.68725L2.58261 8.31303C1.96575 9.38146 2.33183 10.7477 3.40025 11.3645C3.88948 11.647 3.88947 12.3531 3.40026 12.6355C2.33184 13.2524 1.96578 14.6186 2.58263 15.687L4.09863 18.3128C4.71562 19.3814 6.08215 19.7474 7.15067 19.1305C7.64015 18.8479 8.25182 19.2012 8.25182 19.766C8.25182 20.9998 9.25201 22 10.4858 22H13.5182C14.7519 22 15.7518 20.9998 15.7518 19.7663C15.7518 19.2015 16.3632 18.8487 16.852 19.1309C17.9202 19.7476 19.2862 19.3816 19.9029 18.3134L21.4193 15.6869C22.0361 14.6185 21.6701 13.2523 20.6017 12.6355C20.1125 12.3531 20.1125 11.647 20.6017 11.3645C21.6701 10.7477 22.0362 9.38152 21.4193 8.3131L19.903 5.68667C19.2862 4.61842 17.9202 4.25241 16.852 4.86917C16.3632 5.15138 15.7518 4.79856 15.7518 4.23377C15.7518 3.00024 14.7519 2 13.5182 2ZM9.6659 11.9999C9.6659 10.7103 10.7113 9.66493 12.0009 9.66493C13.2905 9.66493 14.3359 10.7103 14.3359 11.9999C14.3359 13.2895 13.2905 14.3349 12.0009 14.3349C10.7113 14.3349 9.6659 13.2895 9.6659 11.9999ZM12.0009 8.16493C9.88289 8.16493 8.1659 9.88191 8.1659 11.9999C8.1659 14.1179 9.88289 15.8349 12.0009 15.8349C14.1189 15.8349 15.8359 14.1179 15.8359 11.9999C15.8359 9.88191 14.1189 8.16493 12.0009 8.16493Z"
fill=""
/>
</svg>
{t("account_settings")}
</DropdownItem>
</li>
{(user?.role === 'admin' || user?.role === 'owner') && (
<li>
<DropdownItem
onItemClick={closeDropdown}
tag="a"
href="/dashboard/admin/smtp-tester"
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
<svg
className="stroke-gray-500 group-hover:stroke-gray-700 dark:stroke-gray-400 dark:group-hover:stroke-gray-300"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.98481 2.44399C3.11333 1.57147 1.15325 3.46979 1.96543 5.36824L3.82086 9.70527C3.90146 9.89367 3.90146 10.1069 3.82086 10.2953L1.96543 14.6323C1.15326 16.5307 3.11332 18.4291 4.98481 17.5565L16.8184 12.0395C18.5508 11.2319 18.5508 8.76865 16.8184 7.961L4.98481 2.44399ZM3.34453 4.77824C3.0738 4.14543 3.72716 3.51266 4.35099 3.80349L16.1846 9.32051C16.762 9.58973 16.762 10.4108 16.1846 10.68L4.35098 16.197C3.72716 16.4879 3.0738 15.8551 3.34453 15.2223L5.19996 10.8853C5.21944 10.8397 5.23735 10.7937 5.2537 10.7473L9.11784 10.7473C9.53206 10.7473 9.86784 10.4115 9.86784 9.99726C9.86784 9.58304 9.53206 9.24726 9.11784 9.24726L5.25157 9.24726C5.2358 9.20287 5.2186 9.15885 5.19996 9.11528L3.34453 4.77824Z"
fill="currentColor"
/>
</svg>
{t("smtp_tester")}
</DropdownItem>
</li>
)}
<li>
<DropdownItem
onItemClick={closeDropdown}
tag="a"
href="/dashboard/support"
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
<svg
className="fill-gray-500 group-hover:fill-gray-700 dark:fill-gray-400 dark:group-hover:fill-gray-300"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.5 12C3.5 7.30558 7.30558 3.5 12 3.5C16.6944 3.5 20.5 7.30558 20.5 12C20.5 16.6944 16.6944 20.5 12 20.5C7.30558 20.5 3.5 16.6944 3.5 12ZM12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM11.0991 7.52507C11.0991 8.02213 11.5021 8.42507 11.9991 8.42507H12.0001C12.4972 8.42507 12.9001 8.02213 12.9001 7.52507C12.9001 7.02802 12.4972 6.62507 12.0001 6.62507H11.9991C11.5021 6.62507 11.0991 7.02802 11.0991 7.52507ZM12.0001 17.3714C11.5859 17.3714 11.2501 17.0356 11.2501 16.6214V10.9449C11.2501 10.5307 11.5859 10.1949 12.0001 10.1949C12.4143 10.1949 12.7501 10.5307 12.7501 10.9449V16.6214C12.7501 17.0356 12.4143 17.3714 12.0001 17.3714Z"
fill=""
/>
</svg>
{t("support")}
</DropdownItem>
</li>
</ul>
<button
onClick={logout}
className="flex w-full items-center gap-3 px-3 py-2 mt-3 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
<svg
className="fill-gray-500 group-hover:fill-gray-700 dark:group-hover:fill-gray-300"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.1007 19.247C14.6865 19.247 14.3507 18.9112 14.3507 18.497L14.3507 14.245H12.8507V18.497C12.8507 19.7396 13.8581 20.747 15.1007 20.747H18.5007C19.7434 20.747 20.7507 19.7396 20.7507 18.497L20.7507 5.49609C20.7507 4.25345 19.7433 3.24609 18.5007 3.24609H15.1007C13.8581 3.24609 12.8507 4.25345 12.8507 5.49609V9.74501L14.3507 9.74501V5.49609C14.3507 5.08188 14.6865 4.74609 15.1007 4.74609L18.5007 4.74609C18.9149 4.74609 19.2507 5.08188 19.2507 5.49609L19.2507 18.497C19.2507 18.9112 18.9149 19.247 18.5007 19.247H15.1007ZM3.25073 11.9984C3.25073 12.2144 3.34204 12.4091 3.48817 12.546L8.09483 17.1556C8.38763 17.4485 8.86251 17.4487 9.15549 17.1559C9.44848 16.8631 9.44863 16.3882 9.15583 16.0952L5.81116 12.7484L16.0007 12.7484C16.4149 12.7484 16.7507 12.4127 16.7507 11.9984C16.7507 11.5842 16.4149 11.2484 16.0007 11.2484L5.81528 11.2484L9.15585 7.90554C9.44864 7.61255 9.44847 7.13767 9.15547 6.84488C8.86248 6.55209 8.3876 6.55226 8.09481 6.84525L3.52309 11.4202C3.35673 11.5577 3.25073 11.7657 3.25073 11.9984Z"
fill=""
/>
</svg>
{t("sign_out")}
</button>
</Dropdown>
</div>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { NextIntlClientProvider, AbstractIntlMessages } from 'next-intl';
import useSWR from 'swr';
import axios from '@/lib/axios';
type Locale = 'en' | 'id';
interface I18nContextType {
locale: Locale;
setLocale: (locale: Locale) => void;
isLoading: boolean;
}
const I18nContext = createContext<I18nContextType | undefined>(undefined);
export const I18nProvider = ({ children }: { children: ReactNode }) => {
const { data: user } = useSWR('/api/user', (url) => axios.get(url).then(res => res.data));
const [locale, setLocaleState] = useState<Locale>('en');
const [messages, setMessages] = useState<AbstractIntlMessages | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Initial load from localStorage
useEffect(() => {
const savedLocale = localStorage.getItem('NEXT_LOCALE') as Locale;
if (savedLocale) {
setLocaleState(savedLocale);
}
}, []);
// Sync with User data when it arrives
useEffect(() => {
if (user?.language && user.language !== locale) {
setLocaleState(user.language as Locale);
}
}, [user?.language]);
// Load messages when locale changes
useEffect(() => {
const loadMessages = async () => {
setIsLoading(true);
try {
const msgs = (await import(`@/messages/${locale}.json`)).default;
setMessages(msgs);
localStorage.setItem('NEXT_LOCALE', locale);
} catch (error) {
console.error('Failed to load messages', error);
} finally {
setIsLoading(false);
}
};
loadMessages();
}, [locale]);
const setLocale = (newLocale: Locale) => {
setLocaleState(newLocale);
};
if (!messages) return null; // Or a global loader
return (
<I18nContext.Provider value={{ locale, setLocale, isLoading }}>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
</I18nContext.Provider>
);
};
export const useI18n = () => {
const context = useContext(I18nContext);
if (!context) throw new Error('useI18n must be used within I18nProvider');
return context;
};

View File

@@ -0,0 +1,211 @@
import React from "react";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
} from "../ui/table";
import Badge from "../ui/badge/Badge";
import Image from "next/image";
import { getUserAvatar } from "@/lib/utils";
interface Order {
id: number;
user: {
name: string;
role: string;
};
projectName: string;
team: {
names: string[];
};
status: string;
budget: string;
}
// Define the table data using the interface
const tableData: Order[] = [
{
id: 1,
user: {
name: "Lindsey Curtis",
role: "Web Designer",
},
projectName: "Agency Website",
team: {
names: ["User 22", "User 23", "User 24"],
},
budget: "3.9K",
status: "Active",
},
{
id: 2,
user: {
name: "Kaiya George",
role: "Project Manager",
},
projectName: "Technology",
team: {
names: ["User 25", "User 26"],
},
budget: "24.9K",
status: "Pending",
},
{
id: 3,
user: {
name: "Zain Geidt",
role: "Content Writing",
},
projectName: "Blog Writing",
team: {
names: ["User 27"],
},
budget: "12.7K",
status: "Active",
},
{
id: 4,
user: {
name: "Abram Schleifer",
role: "Digital Marketer",
},
projectName: "Social Media",
team: {
names: ["User 28", "User 29", "User 30"],
},
budget: "2.8K",
status: "Cancel",
},
{
id: 5,
user: {
name: "Carla George",
role: "Front-end Developer",
},
projectName: "Website",
team: {
names: ["User 31", "User 32", "User 33"],
},
budget: "4.5K",
status: "Active",
},
];
export default function BasicTableOne() {
return (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
<div className="max-w-full overflow-x-auto">
<div className="min-w-[1102px]">
<Table>
{/* Table Header */}
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
<TableRow>
<TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
User
</TableCell>
<TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Project Name
</TableCell>
<TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Team
</TableCell>
<TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Status
</TableCell>
<TableCell
isHeader
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400"
>
Budget
</TableCell>
</TableRow>
</TableHeader>
{/* Table Body */}
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
{tableData.map((order) => (
<TableRow key={order.id}>
<TableCell className="px-5 py-4 sm:px-6 text-start">
<div className="flex items-center gap-3">
<div className="w-10 h-10 overflow-hidden rounded-full">
<Image
width={40}
height={40}
src={getUserAvatar({ name: order.user.name })}
alt={order.user.name}
unoptimized={true}
/>
</div>
<div>
<span className="block font-medium text-gray-800 text-theme-sm dark:text-white/90">
{order.user.name}
</span>
<span className="block text-gray-500 text-theme-xs dark:text-gray-400">
{order.user.role}
</span>
</div>
</div>
</TableCell>
<TableCell className="px-4 py-3 text-gray-500 text-start text-theme-sm dark:text-gray-400">
{order.projectName}
</TableCell>
<TableCell className="px-4 py-3 text-gray-500 text-start text-theme-sm dark:text-gray-400">
<div className="flex -space-x-2">
{order.team.names.map((teamName, index) => (
<div
key={index}
className="w-6 h-6 overflow-hidden border-2 border-white rounded-full dark:border-gray-900"
>
<Image
width={24}
height={24}
src={getUserAvatar({ name: teamName })}
alt={`Team member ${index + 1}`}
className="w-full"
unoptimized={true}
/>
</div>
))}
</div>
</TableCell>
<TableCell className="px-4 py-3 text-gray-500 text-start text-theme-sm dark:text-gray-400">
<Badge
size="sm"
color={
order.status === "Active"
? "success"
: order.status === "Pending"
? "warning"
: "error"
}
>
{order.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-3 text-gray-500 text-theme-sm dark:text-gray-400">
{order.budget}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
type PaginationProps = {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
};
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange,
}) => {
const pagesAroundCurrent = Array.from(
{ length: Math.min(3, totalPages) },
(_, i) => i + Math.max(currentPage - 1, 1)
);
return (
<div className="flex items-center ">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="mr-2.5 flex items-center h-10 justify-center rounded-lg border border-gray-300 bg-white px-3.5 py-2.5 text-gray-700 shadow-theme-xs hover:bg-gray-50 disabled:opacity-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] text-sm"
>
Previous
</button>
<div className="flex items-center gap-2">
{currentPage > 3 && <span className="px-2">...</span>}
{pagesAroundCurrent.map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`px-4 py-2 rounded ${
currentPage === page
? "bg-brand-500 text-white"
: "text-gray-700 dark:text-gray-400"
} flex w-10 items-center justify-center h-10 rounded-lg text-sm font-medium hover:bg-blue-500/[0.08] hover:text-brand-500 dark:hover:text-brand-500`}
>
{page}
</button>
))}
{currentPage < totalPages - 2 && <span className="px-2">...</span>}
</div>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="ml-2.5 flex items-center justify-center rounded-lg border border-gray-300 bg-white px-3.5 py-2.5 text-gray-700 shadow-theme-xs text-sm hover:bg-gray-50 h-10 disabled:opacity-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03]"
>
Next
</button>
</div>
);
};
export default Pagination;

View File

@@ -0,0 +1,22 @@
import React from "react";
interface PageLoaderProps {
text?: string;
className?: string;
}
export default function PageLoader({ text = "Loading...", className = "min-h-[60vh]" }: PageLoaderProps) {
return (
<div className={`flex items-center justify-center ${className}`}>
<div className="flex flex-col items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-brand-50 dark:bg-brand-900/10">
<svg className="animate-spin h-6 w-6 text-brand-500 dark:text-brand-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<span className="text-sm font-medium text-gray-500 dark:text-gray-400 animate-pulse">{text}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import React, { useState } from "react";
interface TooltipProps {
content: string;
children: React.ReactNode;
position?: "top" | "bottom" | "left" | "right" | "top-start" | "top-end" | "bottom-start" | "bottom-end";
className?: string;
}
export default function Tooltip({
content,
children,
position = "top",
className = ""
}: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const positionClasses = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
"top-start": "bottom-full left-0 mb-2",
"top-end": "bottom-full right-0 mb-2",
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
"bottom-start": "top-full left-0 mt-2",
"bottom-end": "top-full right-0 mt-2",
left: "right-full top-1/2 -translate-y-1/2 mr-2",
right: "left-full top-1/2 -translate-y-1/2 ml-2",
};
const arrowClasses = {
top: "top-full left-1/2 -translate-x-1/2 border-t-gray-800",
"top-start": "top-full left-4 border-t-gray-800",
"top-end": "top-full right-4 border-t-gray-800",
bottom: "bottom-full left-1/2 -translate-x-1/2 border-b-gray-800",
"bottom-start": "bottom-full left-4 border-b-gray-800",
"bottom-end": "bottom-full right-4 border-b-gray-800",
left: "left-full top-1/2 -translate-y-1/2 border-l-gray-800",
right: "right-full top-1/2 -translate-y-1/2 border-r-gray-800",
};
return (
<div
className={`relative inline-block ${className}`}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
>
{children}
{isVisible && (
<div className={`absolute z-99999 px-3 py-2 text-xs font-medium text-white bg-gray-900 border border-gray-800 rounded-lg shadow-theme-lg dark:bg-gray-800 dark:border-gray-700 w-max max-w-[180px] sm:max-w-[250px] break-words animate-slide-in ${positionClasses[position]}`}>
{content}
<div className={`absolute border-4 border-transparent ${arrowClasses[position]}`}></div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,145 @@
import Link from "next/link";
import React from "react";
interface AlertProps {
variant: "success" | "error" | "warning" | "info"; // Alert type
title: string; // Title of the alert
message: string; // Message of the alert
showLink?: boolean; // Whether to show the "Learn More" link
linkHref?: string; // Link URL
linkText?: string; // Link text
}
const Alert: React.FC<AlertProps> = ({
variant,
title,
message,
showLink = false,
linkHref = "#",
linkText = "Learn more",
}) => {
// Tailwind classes for each variant
const variantClasses = {
success: {
container:
"border-success-500 bg-success-50 dark:border-success-500/30 dark:bg-success-500/15",
icon: "text-success-500",
},
error: {
container:
"border-error-500 bg-error-50 dark:border-error-500/30 dark:bg-error-500/15",
icon: "text-error-500",
},
warning: {
container:
"border-warning-500 bg-warning-50 dark:border-warning-500/30 dark:bg-warning-500/15",
icon: "text-warning-500",
},
info: {
container:
"border-blue-light-500 bg-blue-light-50 dark:border-blue-light-500/30 dark:bg-blue-light-500/15",
icon: "text-blue-light-500",
},
};
// Icon for each variant
const icons = {
success: (
<svg
className="fill-current"
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.70186 12.0001C3.70186 7.41711 7.41711 3.70186 12.0001 3.70186C16.5831 3.70186 20.2984 7.41711 20.2984 12.0001C20.2984 16.5831 16.5831 20.2984 12.0001 20.2984C7.41711 20.2984 3.70186 16.5831 3.70186 12.0001ZM12.0001 1.90186C6.423 1.90186 1.90186 6.423 1.90186 12.0001C1.90186 17.5772 6.423 22.0984 12.0001 22.0984C17.5772 22.0984 22.0984 17.5772 22.0984 12.0001C22.0984 6.423 17.5772 1.90186 12.0001 1.90186ZM15.6197 10.7395C15.9712 10.388 15.9712 9.81819 15.6197 9.46672C15.2683 9.11525 14.6984 9.11525 14.347 9.46672L11.1894 12.6243L9.6533 11.0883C9.30183 10.7368 8.73198 10.7368 8.38051 11.0883C8.02904 11.4397 8.02904 12.0096 8.38051 12.3611L10.553 14.5335C10.7217 14.7023 10.9507 14.7971 11.1894 14.7971C11.428 14.7971 11.657 14.7023 11.8257 14.5335L15.6197 10.7395Z"
/>
</svg>
),
error: (
<svg
className="fill-current"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M20.3499 12.0004C20.3499 16.612 16.6115 20.3504 11.9999 20.3504C7.38832 20.3504 3.6499 16.612 3.6499 12.0004C3.6499 7.38881 7.38833 3.65039 11.9999 3.65039C16.6115 3.65039 20.3499 7.38881 20.3499 12.0004ZM11.9999 22.1504C17.6056 22.1504 22.1499 17.6061 22.1499 12.0004C22.1499 6.3947 17.6056 1.85039 11.9999 1.85039C6.39421 1.85039 1.8499 6.3947 1.8499 12.0004C1.8499 17.6061 6.39421 22.1504 11.9999 22.1504ZM13.0008 16.4753C13.0008 15.923 12.5531 15.4753 12.0008 15.4753L11.9998 15.4753C11.4475 15.4753 10.9998 15.923 10.9998 16.4753C10.9998 17.0276 11.4475 17.4753 11.9998 17.4753L12.0008 17.4753C12.5531 17.4753 13.0008 17.0276 13.0008 16.4753ZM11.9998 6.62898C12.414 6.62898 12.7498 6.96476 12.7498 7.37898L12.7498 13.0555C12.7498 13.4697 12.414 13.8055 11.9998 13.8055C11.5856 13.8055 11.2498 13.4697 11.2498 13.0555L11.2498 7.37898C11.2498 6.96476 11.5856 6.62898 11.9998 6.62898Z"
fill="#F04438"
/>
</svg>
),
warning: (
<svg
className="fill-current"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.6501 12.0001C3.6501 7.38852 7.38852 3.6501 12.0001 3.6501C16.6117 3.6501 20.3501 7.38852 20.3501 12.0001C20.3501 16.6117 16.6117 20.3501 12.0001 20.3501C7.38852 20.3501 3.6501 16.6117 3.6501 12.0001ZM12.0001 1.8501C6.39441 1.8501 1.8501 6.39441 1.8501 12.0001C1.8501 17.6058 6.39441 22.1501 12.0001 22.1501C17.6058 22.1501 22.1501 17.6058 22.1501 12.0001C22.1501 6.39441 17.6058 1.8501 12.0001 1.8501ZM10.9992 7.52517C10.9992 8.07746 11.4469 8.52517 11.9992 8.52517H12.0002C12.5525 8.52517 13.0002 8.07746 13.0002 7.52517C13.0002 6.97289 12.5525 6.52517 12.0002 6.52517H11.9992C11.4469 6.52517 10.9992 6.97289 10.9992 7.52517ZM12.0002 17.3715C11.586 17.3715 11.2502 17.0357 11.2502 16.6215V10.945C11.2502 10.5308 11.586 10.195 12.0002 10.195C12.4144 10.195 12.7502 10.5308 12.7502 10.945V16.6215C12.7502 17.0357 12.4144 17.3715 12.0002 17.3715Z"
fill=""
/>
</svg>
),
info: (
<svg
className="fill-current"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.6501 11.9996C3.6501 7.38803 7.38852 3.64961 12.0001 3.64961C16.6117 3.64961 20.3501 7.38803 20.3501 11.9996C20.3501 16.6112 16.6117 20.3496 12.0001 20.3496C7.38852 20.3496 3.6501 16.6112 3.6501 11.9996ZM12.0001 1.84961C6.39441 1.84961 1.8501 6.39392 1.8501 11.9996C1.8501 17.6053 6.39441 22.1496 12.0001 22.1496C17.6058 22.1496 22.1501 17.6053 22.1501 11.9996C22.1501 6.39392 17.6058 1.84961 12.0001 1.84961ZM10.9992 7.52468C10.9992 8.07697 11.4469 8.52468 11.9992 8.52468H12.0002C12.5525 8.52468 13.0002 8.07697 13.0002 7.52468C13.0002 6.9724 12.5525 6.52468 12.0002 6.52468H11.9992C11.4469 6.52468 10.9992 6.9724 10.9992 7.52468ZM12.0002 17.371C11.586 17.371 11.2502 17.0352 11.2502 16.621V10.9445C11.2502 10.5303 11.586 10.1945 12.0002 10.1945C12.4144 10.1945 12.7502 10.5303 12.7502 10.9445V16.621C12.7502 17.0352 12.4144 17.371 12.0002 17.371Z"
fill=""
/>
</svg>
),
};
return (
<div
className={`rounded-xl border p-4 ${variantClasses[variant].container}`}
>
<div className="flex items-start gap-3">
<div className={`-mt-0.5 ${variantClasses[variant].icon}`}>
{icons[variant]}
</div>
<div>
<h4 className="mb-1 text-sm font-semibold text-gray-800 dark:text-white/90">
{title}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">{message}</p>
{showLink && (
<Link
href={linkHref}
className="inline-block mt-3 text-sm font-medium text-gray-500 underline dark:text-gray-400"
>
{linkText}
</Link>
)}
</div>
</div>
</div>
);
};
export default Alert;

View File

@@ -0,0 +1,66 @@
import Image from "next/image";
import React from "react";
interface AvatarProps {
src: string; // URL of the avatar image
alt?: string; // Alt text for the avatar
size?: "xsmall" | "small" | "medium" | "large" | "xlarge" | "xxlarge"; // Avatar size
status?: "online" | "offline" | "busy" | "none"; // Status indicator
}
const sizeClasses = {
xsmall: "h-6 w-6 max-w-6",
small: "h-8 w-8 max-w-8",
medium: "h-10 w-10 max-w-10",
large: "h-12 w-12 max-w-12",
xlarge: "h-14 w-14 max-w-14",
xxlarge: "h-16 w-16 max-w-16",
};
const statusSizeClasses = {
xsmall: "h-1.5 w-1.5 max-w-1.5",
small: "h-2 w-2 max-w-2",
medium: "h-2.5 w-2.5 max-w-2.5",
large: "h-3 w-3 max-w-3",
xlarge: "h-3.5 w-3.5 max-w-3.5",
xxlarge: "h-4 w-4 max-w-4",
};
const statusColorClasses = {
online: "bg-success-500",
offline: "bg-error-400",
busy: "bg-warning-500",
};
const Avatar: React.FC<AvatarProps> = ({
src,
alt = "User Avatar",
size = "medium",
status = "none",
}) => {
return (
<div className={`relative rounded-full ${sizeClasses[size]}`}>
{/* Avatar Image */}
<Image
width="0"
height="0"
sizes="100vw"
src={src}
alt={alt}
className="object-cover w-full rounded-full"
unoptimized={true}
/>
{/* Status Indicator */}
{status !== "none" && (
<span
className={`absolute bottom-0 right-0 rounded-full border-[1.5px] border-white dark:border-gray-900 ${
statusSizeClasses[size]
} ${statusColorClasses[status] || ""}`}
></span>
)}
</div>
);
};
export default Avatar;

View File

@@ -0,0 +1,47 @@
import React from "react";
interface AvatarTextProps {
name: string;
className?: string;
}
const AvatarText: React.FC<AvatarTextProps> = ({ name, className = "" }) => {
// Generate initials from name
const initials = name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
// Generate a consistent pastel color based on the name
const getColorClass = (name: string) => {
const colors = [
"bg-brand-100 text-brand-600",
"bg-pink-100 text-pink-600",
"bg-cyan-100 text-cyan-600",
"bg-orange-100 text-orange-600",
"bg-green-100 text-green-600",
"bg-purple-100 text-purple-600",
"bg-yellow-100 text-yellow-600",
"bg-error-100 text-error-600",
];
const index = name
.split("")
.reduce((acc, char) => acc + char.charCodeAt(0), 0);
return colors[index % colors.length];
};
return (
<div
className={`flex h-10 w-10 ${className} items-center justify-center rounded-full ${getColorClass(
name
)}`}
>
<span className="text-sm font-medium">{initials}</span>
</div>
);
};
export default AvatarText;

View File

@@ -0,0 +1,79 @@
import React from "react";
type BadgeVariant = "light" | "solid";
type BadgeSize = "sm" | "md";
type BadgeColor =
| "primary"
| "success"
| "error"
| "warning"
| "info"
| "light"
| "dark";
interface BadgeProps {
variant?: BadgeVariant; // Light or solid variant
size?: BadgeSize; // Badge size
color?: BadgeColor; // Badge color
startIcon?: React.ReactNode; // Icon at the start
endIcon?: React.ReactNode; // Icon at the end
children: React.ReactNode; // Badge content
}
const Badge: React.FC<BadgeProps> = ({
variant = "light",
color = "primary",
size = "md",
startIcon,
endIcon,
children,
}) => {
const baseStyles =
"inline-flex items-center px-2.5 py-0.5 justify-center gap-1 rounded-full font-medium";
// Define size styles
const sizeStyles = {
sm: "text-theme-xs", // Smaller padding and font size
md: "text-sm", // Default padding and font size
};
// Define color styles for variants
const variants = {
light: {
primary:
"bg-brand-50 text-brand-500 dark:bg-brand-500/15 dark:text-brand-400",
success:
"bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500",
error:
"bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500",
warning:
"bg-warning-50 text-warning-600 dark:bg-warning-500/15 dark:text-orange-400",
info: "bg-blue-light-50 text-blue-light-500 dark:bg-blue-light-500/15 dark:text-blue-light-500",
light: "bg-gray-100 text-gray-700 dark:bg-white/5 dark:text-white/80",
dark: "bg-gray-500 text-white dark:bg-white/5 dark:text-white",
},
solid: {
primary: "bg-brand-500 text-white dark:text-white",
success: "bg-success-500 text-white dark:text-white",
error: "bg-error-500 text-white dark:text-white",
warning: "bg-warning-500 text-white dark:text-white",
info: "bg-blue-light-500 text-white dark:text-white",
light: "bg-gray-400 dark:bg-white/5 text-white dark:text-white/80",
dark: "bg-gray-700 text-white dark:text-white",
},
};
// Get styles based on size and color variant
const sizeClass = sizeStyles[size];
const colorStyles = variants[variant][color];
return (
<span className={`${baseStyles} ${sizeClass} ${colorStyles}`}>
{startIcon && <span className="mr-1">{startIcon}</span>}
{children}
{endIcon && <span className="ml-1">{endIcon}</span>}
</span>
);
};
export default Badge;

View File

@@ -0,0 +1,89 @@
import React, { ReactNode } from "react";
interface ButtonProps {
children: ReactNode; // Button text or content
size?: "sm" | "md"; // Button size
variant?: "primary" | "outline" | "danger" | "success"; // Button variant
startIcon?: ReactNode; // Icon before the text
endIcon?: ReactNode; // Icon after the text
onClick?: () => void; // Click handler
disabled?: boolean; // Disabled state
loading?: boolean; // Loading state
className?: string; // Additional classes
type?: "button" | "submit" | "reset"; // Button type
}
const Button: React.FC<ButtonProps> = ({
children,
size = "md",
variant = "primary",
startIcon,
endIcon,
onClick,
className = "",
disabled = false,
loading = false,
type = "button",
}) => {
// Size Classes
const sizeClasses = {
sm: "px-4 py-3 text-sm",
md: "px-5 py-3.5 text-sm",
};
// Variant Classes
const variantClasses = {
primary:
"bg-brand-500 text-white shadow-theme-xs hover:bg-brand-600 disabled:bg-brand-300",
outline:
"bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700 dark:hover:bg-white/[0.03] dark:hover:text-gray-300",
danger:
"bg-red-600 text-white shadow-theme-xs hover:bg-red-700 disabled:bg-red-300 dark:bg-red-600 dark:hover:bg-red-700",
success:
"bg-green-600 text-white shadow-theme-xs hover:bg-green-700 disabled:bg-green-300 dark:bg-green-600 dark:hover:bg-green-700",
};
return (
<button
type={type}
className={`inline-flex items-center justify-center font-medium gap-2 rounded-lg transition ${className} ${
sizeClasses[size]
} ${variantClasses[variant]} ${
disabled || loading ? "cursor-not-allowed opacity-70" : ""
}`}
onClick={onClick}
disabled={disabled || loading}
>
{loading ? (
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-current"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
startIcon && <span className="flex items-center">{startIcon}</span>
)}
{children}
{!loading && endIcon && (
<span className="flex items-center">{endIcon}</span>
)}
</button>
);
};
export default Button;

View File

@@ -0,0 +1,48 @@
"use client";
import type React from "react";
import { useEffect, useRef } from "react";
interface DropdownProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
}
export const Dropdown: React.FC<DropdownProps> = ({
isOpen,
onClose,
children,
className = "",
}) => {
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
!(event.target as HTMLElement).closest('.dropdown-toggle')
) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [onClose]);
if (!isOpen) return null;
return (
<div
ref={dropdownRef}
className={`absolute z-40 right-0 mt-2 rounded-xl border border-gray-200 bg-white shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark ${className}`}
>
{children}
</div>
);
};

View File

@@ -0,0 +1,46 @@
import type React from "react";
import Link from "next/link";
interface DropdownItemProps {
tag?: "a" | "button";
href?: string;
onClick?: () => void;
onItemClick?: () => void;
baseClassName?: string;
className?: string;
children: React.ReactNode;
}
export const DropdownItem: React.FC<DropdownItemProps> = ({
tag = "button",
href,
onClick,
onItemClick,
baseClassName = "block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900",
className = "",
children,
}) => {
const combinedClasses = `${baseClassName} ${className}`.trim();
const handleClick = (event: React.MouseEvent) => {
if (tag === "button") {
event.preventDefault();
}
if (onClick) onClick();
if (onItemClick) onItemClick();
};
if (tag === "a" && href) {
return (
<Link href={href} className={combinedClasses} onClick={handleClick}>
{children}
</Link>
);
}
return (
<button onClick={handleClick} className={combinedClasses}>
{children}
</button>
);
};

View File

@@ -0,0 +1,18 @@
import Image from "next/image";
import React from "react";
export default function ResponsiveImage() {
return (
<div className="relative">
<div className="overflow-hidden">
<Image
src="/images/grid-image/image-01.png"
alt="Cover"
className="w-full border border-gray-200 rounded-xl dark:border-gray-800"
width={1054}
height={600}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import Image from "next/image";
import React from "react";
export default function ThreeColumnImageGrid() {
return (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
<div>
<Image
src="/images/grid-image/image-04.png"
alt=" grid"
className="w-full border border-gray-200 rounded-xl dark:border-gray-800"
width={338}
height={192}
/>
</div>
<div>
<Image
src="/images/grid-image/image-05.png"
alt=" grid"
className="w-full border border-gray-200 rounded-xl dark:border-gray-800"
width={338}
height={192}
/>
</div>
<div>
<Image
src="/images/grid-image/image-06.png"
alt=" grid"
className="w-full border border-gray-200 rounded-xl dark:border-gray-800"
width={338}
height={192}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import Image from "next/image";
import React from "react";
export default function TwoColumnImageGrid() {
return (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<Image
src="/images/grid-image/image-02.png"
alt=" grid"
className="w-full border border-gray-200 rounded-xl dark:border-gray-800"
width={517}
height={295}
/>
</div>
<div>
<Image
src="/images/grid-image/image-03.png"
alt=" grid"
className="w-full border border-gray-200 rounded-xl dark:border-gray-800"
width={517}
height={295}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import React, { useRef, useEffect } from "react";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
className?: string;
children: React.ReactNode;
showCloseButton?: boolean; // New prop to control close button visibility
isFullscreen?: boolean; // Default to false for backwards compatibility
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
children,
className,
showCloseButton = true, // Default to true for backwards compatibility
isFullscreen = false,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
}
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, onClose]);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [isOpen]);
if (!isOpen) return null;
const contentClasses = isFullscreen
? "w-full h-full"
: "relative w-full rounded-3xl bg-white dark:bg-gray-900";
return (
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-[100000]">
{!isFullscreen && (
<div
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
onClick={onClose}
></div>
)}
<div
ref={modalRef}
className={`${contentClasses} ${className}`}
onClick={(e) => e.stopPropagation()}
>
{showCloseButton && (
<button
onClick={onClose}
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
fill="currentColor"
/>
</svg>
</button>
)}
<div>{children}</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,69 @@
import React, { ReactNode } from "react";
// Props for Table
interface TableProps {
children: ReactNode; // Table content (thead, tbody, etc.)
className?: string; // Optional className for styling
}
// Props for TableHeader
interface TableHeaderProps {
children: ReactNode; // Header row(s)
className?: string; // Optional className for styling
}
// Props for TableBody
interface TableBodyProps {
children: ReactNode; // Body row(s)
className?: string; // Optional className for styling
}
// Props for TableRow
interface TableRowProps {
children: ReactNode; // Cells (th or td)
className?: string; // Optional className for styling
}
// Props for TableCell
interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement> {
children: ReactNode; // Cell content
isHeader?: boolean; // If true, renders as <th>, otherwise <td>
className?: string; // Optional className for styling
}
// Table Component
const Table: React.FC<TableProps> = ({ children, className }) => {
return <table className={`min-w-full ${className}`}>{children}</table>;
};
// TableHeader Component
const TableHeader: React.FC<TableHeaderProps> = ({ children, className }) => {
return <thead className={className}>{children}</thead>;
};
// TableBody Component
const TableBody: React.FC<TableBodyProps> = ({ children, className }) => {
return <tbody className={className}>{children}</tbody>;
};
// TableRow Component
const TableRow: React.FC<TableRowProps> = ({ children, className }) => {
return <tr className={className}>{children}</tr>;
};
// TableCell Component
const TableCell: React.FC<TableCellProps> = ({
children,
isHeader = false,
className,
...rest
}) => {
const CellTag = isHeader ? "th" : "td";
return (
<CellTag className={` ${className}`} {...rest}>
{children}
</CellTag>
);
};
export { Table, TableHeader, TableBody, TableRow, TableCell };

View File

@@ -0,0 +1,66 @@
"use client";
import React from "react";
import { useToast, ToastMessage, ToastType } from "@/context/ToastContext";
import {
CheckCircleIcon,
ErrorIcon,
AlertIcon,
InfoIcon,
CloseIcon,
} from "@/icons";
const Toast: React.FC<{ toast: ToastMessage }> = ({ toast }) => {
const { removeToast } = useToast();
const icons = {
success: <CheckCircleIcon className="w-5 h-5 text-green-500" />,
error: <ErrorIcon className="w-5 h-5 text-red-500" />,
warning: <AlertIcon className="w-5 h-5 text-yellow-500" />,
info: <InfoIcon className="w-5 h-5 text-blue-500" />,
};
const bgClasses = {
success: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800",
error: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800",
warning: "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800",
info: "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800",
};
return (
<div
className={`flex items-center w-full max-w-xs p-4 mb-4 text-gray-500 rounded-lg shadow-lg border transition-all duration-300 animate-slide-in ${bgClasses[toast.type]}`}
role="alert"
>
<div className="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg">
{icons[toast.type]}
</div>
<div className="ms-3 text-sm font-normal text-gray-800 dark:text-gray-200">
{toast.message}
</div>
<button
type="button"
className="ms-auto -mx-1.5 -my-1.5 p-1.5 inline-flex items-center justify-center h-8 w-8 text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 hover:bg-gray-100 dark:text-gray-500 dark:hover:text-white dark:hover:bg-gray-700"
onClick={() => removeToast(toast.id)}
aria-label="Close"
>
<span className="sr-only">Close</span>
<CloseIcon className="w-4 h-4" />
</button>
</div>
);
};
export const ToastContainer: React.FC = () => {
const { toasts } = useToast();
return (
<div className="fixed bottom-5 right-5 z-[999999] flex flex-col items-end pointer-events-none w-full max-w-xs">
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto w-full transition-all duration-500 ease-in-out">
<Toast toast={toast} />
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,28 @@
import React from "react";
import YouTubeEmbed from "./YouTubeEmbed";
import ComponentCard from "@/components/common/ComponentCard";
export default function VideosExample() {
return (
<div>
<div className="grid grid-cols-1 gap-5 sm:gap-6 xl:grid-cols-2">
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Video Ratio 16:9">
<YouTubeEmbed videoId="dQw4w9WgXcQ" />
</ComponentCard>
<ComponentCard title="Video Ratio 4:3">
<YouTubeEmbed videoId="dQw4w9WgXcQ" aspectRatio="4:3" />
</ComponentCard>
</div>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Video Ratio 21:9">
<YouTubeEmbed videoId="dQw4w9WgXcQ" aspectRatio="21:9" />
</ComponentCard>
<ComponentCard title="Video Ratio 1:1">
<YouTubeEmbed videoId="dQw4w9WgXcQ" aspectRatio="1:1" />
</ComponentCard>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import React from "react";
type AspectRatio = "16:9" | "4:3" | "21:9" | "1:1";
interface YouTubeEmbedProps {
videoId: string;
aspectRatio?: AspectRatio;
title?: string;
className?: string;
}
const YouTubeEmbed: React.FC<YouTubeEmbedProps> = ({
videoId,
aspectRatio = "16:9",
title = "YouTube video",
className = "",
}) => {
const aspectRatioClass = {
"16:9": "aspect-video",
"4:3": "aspect-4/3",
"21:9": "aspect-21/9",
"1:1": "aspect-square",
}[aspectRatio];
return (
<div
className={`overflow-hidden rounded-lg ${aspectRatioClass} ${className}`}
>
<iframe
src={`https://www.youtube.com/embed/${videoId}`}
title={title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full"
></iframe>
</div>
);
};
export default YouTubeEmbed;

View File

@@ -0,0 +1,201 @@
"use client";
import useSWR, { mutate } from "swr";
import axios from "@/lib/axios";
import { useToast } from "@/context/ToastContext";
import React from "react";
import { useModal } from "../../hooks/useModal";
import { Modal } from "../ui/modal";
import Button from "../ui/button/Button";
import Input from "../form/input/InputField";
import Label from "../form/Label";
import { useTranslations } from "next-intl";
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
export default function UserAddressCard() {
const { data: user, isLoading: userLoading } = useSWR("/api/user", fetcher);
const { isOpen, openModal, closeModal } = useModal();
const { addToast } = useToast();
const t = useTranslations("Profile");
const [isSaving, setIsSaving] = React.useState(false);
// Form states
const [formData, setFormData] = React.useState({
country: "",
city_state: "",
postal_code: "",
tax_id: "",
});
React.useEffect(() => {
if (user) {
setFormData({
country: user.country || "",
city_state: user.city_state || "",
postal_code: user.postal_code || "",
tax_id: user.tax_id || "",
});
}
}, [user]);
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
await axios.patch("/api/profile", formData);
mutate("/api/user");
addToast(t("toast_address_success"), "success");
closeModal();
} catch (err) {
addToast(t("toast_address_error"), "error");
} finally {
setIsSaving(false);
}
};
if (userLoading) return null;
return (
<>
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div>
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-6">
{t("address_title")}
</h4>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-7 2xl:gap-x-32">
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
{t("country_label")}
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
{user?.country || "-"}
</p>
</div>
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
{t("city_state_label")}
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
{user?.city_state || "-"}
</p>
</div>
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
{t("postal_code_label")}
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
{user?.postal_code || "-"}
</p>
</div>
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
{t("tax_id_label")}
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
{user?.tax_id || "-"}
</p>
</div>
</div>
</div>
<button
onClick={openModal}
className="flex w-full items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 lg:inline-flex lg:w-auto"
>
<svg
className="fill-current"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.0911 2.78206C14.2125 1.90338 12.7878 1.90338 11.9092 2.78206L4.57524 10.116C4.26682 10.4244 4.0547 10.8158 3.96468 11.2426L3.31231 14.3352C3.25997 14.5833 3.33653 14.841 3.51583 15.0203C3.69512 15.1996 3.95286 15.2761 4.20096 15.2238L7.29355 14.5714C7.72031 14.4814 8.11172 14.2693 8.42013 13.9609L15.7541 6.62695C16.6327 5.74827 16.6327 4.32365 15.7541 3.44497L15.0911 2.78206ZM12.9698 3.84272C13.2627 3.54982 13.7376 3.54982 14.0305 3.84272L14.6934 4.50563C14.9863 4.79852 14.9863 5.2734 14.6934 5.56629L14.044 6.21573L12.3204 4.49215L12.9698 3.84272ZM11.2597 5.55281L5.6359 11.1766C5.53309 11.2794 5.46238 11.4099 5.43238 11.5522L5.01758 13.5185L6.98394 13.1037C7.1262 13.0737 7.25666 13.003 7.35947 12.9002L12.9833 7.27639L11.2597 5.55281Z"
fill=""
/>
</svg>
{t("edit_button")}
</button>
</div>
</div>
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[700px] m-4">
<div className="relative w-full p-4 overflow-y-auto bg-white no-scrollbar rounded-3xl dark:bg-gray-900 lg:p-11">
<div className="px-2 pr-14">
<h4 className="mb-2 text-2xl font-semibold text-gray-800 dark:text-white/90">
{t("edit_address_title")}
</h4>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400 lg:mb-7">
{t("edit_address_subtitle")}
</p>
</div>
<form className="flex flex-col" onSubmit={handleSave}>
<div className="px-2 overflow-y-auto custom-scrollbar">
<div className="grid grid-cols-1 gap-x-6 gap-y-5 lg:grid-cols-2">
<div>
<Label>{t("country_label")}</Label>
<Input
type="text"
value={formData.country}
onChange={(e) => handleInputChange("country", e.target.value)}
placeholder={t("country_placeholder")}
/>
</div>
<div>
<Label>{t("city_state_label")}</Label>
<Input
type="text"
value={formData.city_state}
onChange={(e) => handleInputChange("city_state", e.target.value)}
placeholder={t("city_state_placeholder")}
/>
</div>
<div>
<Label>{t("postal_code_label")}</Label>
<Input
type="text"
value={formData.postal_code}
onChange={(e) => handleInputChange("postal_code", e.target.value)}
placeholder={t("postal_code_placeholder")}
/>
</div>
<div>
<Label>{t("tax_id_label")}</Label>
<Input
type="text"
value={formData.tax_id}
onChange={(e) => handleInputChange("tax_id", e.target.value)}
placeholder={t("tax_id_placeholder")}
/>
</div>
</div>
</div>
<div className="flex items-center gap-3 px-2 mt-6 lg:justify-end">
<Button size="sm" variant="outline" type="button" onClick={closeModal} disabled={isSaving}>
{t("close_button")}
</Button>
<Button size="sm" type="submit" loading={isSaving}>
{t("save_changes_button")}
</Button>
</div>
</form>
</div>
</Modal>
</>
);
}

View File

@@ -0,0 +1,285 @@
"use client";
import useSWR, { mutate } from "swr";
import axios from "@/lib/axios";
import { useToast } from "@/context/ToastContext";
import React from "react";
import { useModal } from "../../hooks/useModal";
import { Modal } from "../ui/modal";
import Button from "../ui/button/Button";
import Input from "../form/input/InputField";
import Label from "../form/Label";
import { useTranslations } from "next-intl";
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
export default function UserInfoCard() {
const { data: user, isLoading: userLoading } = useSWR("/api/user", fetcher);
const { isOpen, openModal, closeModal } = useModal();
const { addToast } = useToast();
const t = useTranslations("Profile");
const [isSaving, setIsSaving] = React.useState(false);
// Form states
const [formData, setFormData] = React.useState({
first_name: "",
last_name: "",
email: "",
phone: "",
bio: "",
facebook: "",
twitter: "",
linkedin: "",
instagram: "",
});
React.useEffect(() => {
if (user) {
setFormData({
first_name: user.first_name || "",
last_name: user.last_name || "",
email: user.email || "",
phone: user.phone || "",
bio: user.bio || "",
facebook: user.facebook || "",
twitter: user.twitter || "",
linkedin: user.linkedin || "",
instagram: user.instagram || "",
});
}
}, [user]);
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
await axios.patch("/api/profile", formData);
mutate("/api/user");
addToast(t("toast_personal_info_success"), "success");
closeModal();
} catch (err) {
addToast(t("toast_personal_info_error"), "error");
} finally {
setIsSaving(false);
}
};
if (userLoading) return null;
return (
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div>
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-6">
{t("personal_info_title")}
</h4>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-7 2xl:gap-x-32">
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
{t("first_name_label")}
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
{user?.first_name || "-"}
</p>
</div>
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
{t("last_name_label")}
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
{user?.last_name || "-"}
</p>
</div>
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
{t("email_label")}
</p>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
{user?.email}
</p>
{user?.pending_email && (
<span className="text-[10px] bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400 px-2 py-0.5 rounded-full inline-block w-fit font-medium">
{t("pending_email_notice", { email: user.pending_email })}
</span>
)}
</div>
</div>
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
{t("phone_label")}
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
{user?.phone || "-"}
</p>
</div>
<div className="lg:col-span-2">
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
{t("bio_label")}
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
{user?.bio || "-"}
</p>
</div>
</div>
</div>
<button
onClick={openModal}
className="flex w-full items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 lg:inline-flex lg:w-auto"
>
<svg
className="fill-current"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.0911 2.78206C14.2125 1.90338 12.7878 1.90338 11.9092 2.78206L4.57524 10.116C4.26682 10.4244 4.0547 10.8158 3.96468 11.2426L3.31231 14.3352C3.25997 14.5833 3.33653 14.841 3.51583 15.0203C3.69512 15.1996 3.95286 15.2761 4.20096 15.2238L7.29355 14.5714C7.72031 14.4814 8.11172 14.2693 8.42013 13.9609L15.7541 6.62695C16.6327 5.74827 16.6327 4.32365 15.7541 3.44497L15.0911 2.78206ZM12.9698 3.84272C13.2627 3.54982 13.7376 3.54982 14.0305 3.84272L14.6934 4.50563C14.9863 4.79852 14.9863 5.2734 14.6934 5.56629L14.044 6.21573L12.3204 4.49215L12.9698 3.84272ZM11.2597 5.55281L5.6359 11.1766C5.53309 11.2794 5.46238 11.4099 5.43238 11.5522L5.01758 13.5185L6.98394 13.1037C7.1262 13.0737 7.25666 13.003 7.35947 12.9002L12.9833 7.27639L11.2597 5.55281Z"
fill=""
/>
</svg>
{t("edit_button")}
</button>
</div>
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[700px] m-4">
<div className="no-scrollbar relative w-full max-w-[700px] overflow-y-auto rounded-3xl bg-white p-4 dark:bg-gray-900 lg:p-11">
<div className="px-2 pr-14">
<h4 className="mb-2 text-2xl font-semibold text-gray-800 dark:text-white/90">
{t("edit_personal_info_title")}
</h4>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400 lg:mb-7">
{t("edit_personal_info_subtitle")}
</p>
</div>
<form className="flex flex-col" onSubmit={handleSave}>
<div className="custom-scrollbar h-[450px] overflow-y-auto px-2 pb-3">
<div>
<h5 className="mb-5 text-lg font-medium text-gray-800 dark:text-white/90 lg:mb-6">
{t("social_links_title")}
</h5>
<div className="grid grid-cols-1 gap-x-6 gap-y-5 lg:grid-cols-2">
<div>
<Label>{t("facebook_username_label")}</Label>
<Input
type="text"
value={formData.facebook}
onChange={(e) => handleInputChange("facebook", e.target.value)}
placeholder={t("username_placeholder")}
/>
</div>
<div>
<Label>{t("twitter_username_label")}</Label>
<Input
type="text"
value={formData.twitter}
onChange={(e) => handleInputChange("twitter", e.target.value)}
placeholder={t("username_placeholder")}
/>
</div>
<div>
<Label>{t("linkedin_username_label")}</Label>
<Input
type="text"
value={formData.linkedin}
onChange={(e) => handleInputChange("linkedin", e.target.value)}
placeholder="username"
/>
</div>
<div>
<Label>{t("instagram_username_label")}</Label>
<Input
type="text"
value={formData.instagram}
onChange={(e) => handleInputChange("instagram", e.target.value)}
placeholder={t("username_placeholder")}
/>
</div>
</div>
</div>
<div className="mt-7">
<h5 className="mb-5 text-lg font-medium text-gray-800 dark:text-white/90 lg:mb-6">
{t("personal_info_title")}
</h5>
<div className="grid grid-cols-1 gap-x-6 gap-y-5 lg:grid-cols-2">
<div className="col-span-2 lg:col-span-1">
<Label>{t("first_name_label")}</Label>
<Input
type="text"
value={formData.first_name}
onChange={(e) => handleInputChange("first_name", e.target.value)}
/>
</div>
<div className="col-span-2 lg:col-span-1">
<Label>{t("last_name_label")}</Label>
<Input
type="text"
value={formData.last_name}
onChange={(e) => handleInputChange("last_name", e.target.value)}
/>
</div>
<div className="col-span-2 lg:col-span-1">
<Label>{t("email_label")}</Label>
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
placeholder={t("email_placeholder")}
/>
</div>
<div className="col-span-2 lg:col-span-1">
<Label>{t("phone_label")}</Label>
<Input
type="text"
value={formData.phone}
onChange={(e) => handleInputChange("phone", e.target.value)}
/>
</div>
<div className="col-span-2">
<Label>{t("bio_label")}</Label>
<Input
type="text"
value={formData.bio}
onChange={(e) => handleInputChange("bio", e.target.value)}
/>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-3 px-2 mt-6 lg:justify-end">
<Button size="sm" variant="outline" type="button" onClick={closeModal} disabled={isSaving}>
{t("close_button")}
</Button>
<Button size="sm" type="submit" loading={isSaving}>
{t("save_changes_button")}
</Button>
</div>
</form>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,300 @@
"use client";
import useSWR, { mutate } from "swr";
import axios from "@/lib/axios";
import ImageCropper from "../common/ImageCropper";
import { useToast } from "@/context/ToastContext";
import React from "react";
import { useModal } from "../../hooks/useModal";
import { Modal } from "../ui/modal";
import Button from "../ui/button/Button";
import Input from "../form/input/InputField";
import Label from "../form/Label";
import Image from "next/image";
import { getUserAvatar } from "@/lib/utils";
import { useTranslations } from "next-intl";
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
export default function UserMetaCard() {
const { data: user, isLoading: userLoading } = useSWR("/api/user", fetcher);
const { isOpen, openModal, closeModal } = useModal();
const { addToast } = useToast();
const t = useTranslations("Profile");
const [selectedFile, setSelectedFile] = React.useState<string | null>(null);
const [isCropperOpen, setIsCropperOpen] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
// Form states for Meta Info
const [jobTitle, setJobTitle] = React.useState("");
const [location, setLocation] = React.useState("");
React.useEffect(() => {
if (user) {
setJobTitle(user.job_title || "");
setLocation(user.location || "");
}
}, [user]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
// Check for file size limit (5MB)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
addToast(t("toast_file_size_error"), "error");
// Reset input
e.target.value = "";
return;
}
const reader = new FileReader();
reader.addEventListener("load", () => {
setSelectedFile(reader.result as string);
setIsCropperOpen(true);
});
reader.readAsDataURL(file);
}
};
const handleCropComplete = async (croppedImage: Blob) => {
const formData = new FormData();
formData.append("avatar", croppedImage, "avatar.png");
try {
addToast(t("toast_updating_avatar"), "info");
await axios.post("/api/profile/avatar", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
mutate("/api/user");
mutate("/api/dashboard");
addToast(t("toast_avatar_success"), "success");
} catch (err) {
addToast(t("toast_avatar_error"), "error");
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
await axios.patch("/api/profile", {
job_title: jobTitle,
location: location,
});
mutate("/api/user");
addToast(t("toast_profile_success"), "success");
closeModal();
} catch (err) {
addToast(t("toast_profile_error"), "error");
} finally {
setIsSaving(false);
}
};
if (userLoading) return null;
return (
<>
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
<div className="flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-col items-center w-full gap-6 xl:flex-row">
<div className="group relative w-20 h-20 overflow-hidden border border-gray-200 rounded-full dark:border-gray-800">
<Image
width={80}
height={80}
src={getUserAvatar(user)}
alt="user"
className="object-cover w-full h-full"
unoptimized={true}
/>
<label className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100 cursor-pointer">
<span className="text-white text-[10px] font-medium uppercase">{t("change_avatar")}</span>
<input type="file" className="hidden" accept="image/*" onChange={handleFileChange} />
</label>
</div>
<div className="order-3 xl:order-2">
<h4 className="mb-2 text-lg font-semibold text-center text-gray-800 dark:text-white/90 xl:text-left">
{user?.first_name} {user?.last_name}
</h4>
<div className="flex flex-col items-center gap-1 text-center xl:flex-row xl:gap-3 xl:text-left">
<p className="text-sm text-gray-500 dark:text-gray-400">
{user?.job_title || t("no_job_title")}
</p>
<div className="hidden h-3.5 w-px bg-gray-300 dark:bg-gray-700 xl:block"></div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{user?.location || t("no_location")}
</p>
</div>
</div>
<div className="flex items-center order-2 gap-2 grow xl:order-3 xl:justify-end">
<a
target="_blank"
rel="noreferrer"
href={user?.facebook ? `https://facebook.com/${user.facebook}` : '#'}
className={`flex h-11 w-11 items-center justify-center gap-2 rounded-full border border-gray-300 bg-white text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 ${!user?.facebook && 'opacity-50 pointer-events-none'}`}
>
<svg
className="fill-current"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.6666 11.2503H13.7499L14.5833 7.91699H11.6666V6.25033C11.6666 5.39251 11.6666 4.58366 13.3333 4.58366H14.5833V1.78374C14.3118 1.7477 13.2858 1.66699 12.2023 1.66699C9.94025 1.66699 8.33325 3.04771 8.33325 5.58342V7.91699H5.83325V11.2503H8.33325V18.3337H11.6666V11.2503Z"
fill=""
/>
</svg>
</a>
<a
href={user?.twitter ? `https://x.com/${user.twitter}` : '#'}
target="_blank"
rel="noreferrer"
className={`flex h-11 w-11 items-center justify-center gap-2 rounded-full border border-gray-300 bg-white text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 ${!user?.twitter && 'opacity-50 pointer-events-none'}`}
>
<svg
className="fill-current"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.1708 1.875H17.9274L11.9049 8.75833L18.9899 18.125H13.4424L9.09742 12.4442L4.12578 18.125H1.36745L7.80912 10.7625L1.01245 1.875H6.70078L10.6283 7.0675L15.1708 1.875ZM14.2033 16.475H15.7308L5.87078 3.43833H4.23162L14.2033 16.475Z"
fill=""
/>
</svg>
</a>
<a
href={user?.linkedin ? `https://linkedin.com/in/${user.linkedin}` : "#"}
target="_blank"
rel="noreferrer"
className={`flex h-11 w-11 items-center justify-center gap-2 rounded-full border border-gray-300 bg-white text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 ${!user?.linkedin && 'opacity-50 pointer-events-none'}`}
>
<svg
className="fill-current"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.78381 4.16645C5.78351 4.84504 5.37181 5.45569 4.74286 5.71045C4.11391 5.96521 3.39331 5.81321 2.92083 5.32613C2.44836 4.83904 2.31837 4.11413 2.59216 3.49323C2.86596 2.87233 3.48886 2.47942 4.16715 2.49978C5.06804 2.52682 5.78422 3.26515 5.78381 4.16645ZM5.83381 7.06645H2.50048V17.4998H5.83381V7.06645ZM11.1005 7.06645H7.78381V17.4998H11.0672V12.0248C11.0672 8.97475 15.0422 8.69142 15.0422 12.0248V17.4998H18.3338V10.8914C18.3338 5.74978 12.4505 5.94145 11.0672 8.46642L11.1005 7.06645Z"
fill=""
/>
</svg>
</a>
<a
href={user?.instagram ? `https://instagram.com/${user.instagram}` : '#'}
target="_blank"
rel="noreferrer"
className={`flex h-11 w-11 items-center justify-center gap-2 rounded-full border border-gray-300 bg-white text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 ${!user?.instagram && 'opacity-50 pointer-events-none'}`}
>
<svg
className="fill-current"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.8567 1.66699C11.7946 1.66854 12.2698 1.67351 12.6805 1.68573L12.8422 1.69102C13.0291 1.69766 13.2134 1.70599 13.4357 1.71641C14.3224 1.75738 14.9273 1.89766 15.4586 2.10391C16.0078 2.31572 16.4717 2.60183 16.9349 3.06503C17.3974 3.52822 17.6836 3.99349 17.8961 4.54141C18.1016 5.07197 18.2419 5.67753 18.2836 6.56433C18.2935 6.78655 18.3015 6.97088 18.3081 7.15775L18.3133 7.31949C18.3255 7.73011 18.3311 8.20543 18.3328 9.1433L18.3335 9.76463C18.3336 9.84055 18.3336 9.91888 18.3336 9.99972L18.3335 10.2348L18.333 10.8562C18.3314 11.794 18.3265 12.2694 18.3142 12.68L18.3089 12.8417C18.3023 13.0286 18.294 13.213 18.2836 13.4351C18.2426 14.322 18.1016 14.9268 17.8961 15.458C17.6842 16.0074 17.3974 16.4713 16.9349 16.9345C16.4717 17.397 16.0057 17.6831 15.4586 17.8955C14.9273 18.1011 14.3224 18.2414 13.4357 18.2831C13.2134 18.293 13.0291 18.3011 12.8422 18.3076L12.6805 18.3128C12.2698 18.3251 11.7946 18.3306 10.8567 18.3324L10.2353 18.333C10.1594 18.333 10.0811 18.333 10.0002 18.333H9.76516L9.14375 18.3325C8.20591 18.331 7.7306 18.326 7.31997 18.3137L7.15824 18.3085C6.97136 18.3018 6.78703 18.2935 6.56481 18.2831C5.67801 18.2421 5.07384 18.1011 4.5419 17.8955C3.99328 17.6838 3.5287 17.397 3.06551 16.9345C2.60231 16.4713 2.3169 16.0053 2.1044 15.458C1.89815 14.9268 1.75856 14.322 1.7169 13.4351C1.707 13.213 1.69892 13.0286 1.69238 12.8417L1.68714 12.68C1.67495 12.2694 1.66939 11.794 1.66759 10.8562L1.66748 9.1433C1.66903 8.20543 1.67399 7.73011 1.68621 7.31949L1.69151 7.15775C1.69815 6.97088 1.70648 6.78655 1.7169 6.56433C1.75786 5.67683 1.89815 5.07266 2.1044 4.54141C2.3162 3.9928 2.60231 3.52822 3.06551 3.06503C3.5287 2.60183 3.99398 2.31641 4.5419 2.10391C5.07315 1.89766 5.67731 1.75808 6.56481 1.71641C6.78703 1.70652 6.97136 1.69844 7.15824 1.6919L7.31997 1.68666C7.7306 1.67446 8.20591 1.6689 9.14375 1.6671L10.8567 1.66699ZM10.0002 5.83308C7.69781 5.83308 5.83356 7.69935 5.83356 9.99972C5.83356 12.3021 7.69984 14.1664 10.0002 14.1664C12.3027 14.1664 14.1669 12.3001 14.1669 9.99972C14.1669 7.69732 12.3006 5.83308 10.0002 5.83308ZM10.0002 7.49974C11.381 7.49974 12.5002 8.61863 12.5002 9.99972C12.5002 11.3805 11.3813 12.4997 10.0002 12.4997C8.6195 12.4997 7.50023 11.3809 7.50023 9.99972C7.50023 8.61897 8.61908 7.49974 10.0002 7.49974ZM14.3752 4.58308C13.8008 4.58308 13.3336 5.04967 13.3336 5.62403C13.3336 6.19841 13.8002 6.66572 14.3752 6.66572C14.9496 6.66572 15.4169 6.19913 15.4169 5.62403C15.4169 5.04967 14.9488 4.58236 14.3752 4.58308Z"
fill=""
/>
</svg>
</a>
</div>
</div>
<button
onClick={openModal}
className="flex w-full items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 lg:inline-flex lg:w-auto"
>
<svg
className="fill-current"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.0911 2.78206C14.2125 1.90338 12.7878 1.90338 11.9092 2.78206L4.57524 10.116C4.26682 10.4244 4.0547 10.8158 3.96468 11.2426L3.31231 14.3352C3.25997 14.5833 3.33653 14.841 3.51583 15.0203C3.69512 15.1996 3.95286 15.2761 4.20096 15.2238L7.29355 14.5714C7.72031 14.4814 8.11172 14.2693 8.42013 13.9609L15.7541 6.62695C16.6327 5.74827 16.6327 4.32365 15.7541 3.44497L15.0911 2.78206ZM12.9698 3.84272C13.2627 3.54982 13.7376 3.54982 14.0305 3.84272L14.6934 4.50563C14.9863 4.79852 14.9863 5.2734 14.6934 5.56629L14.044 6.21573L12.3204 4.49215L12.9698 3.84272ZM11.2597 5.55281L5.6359 11.1766C5.53309 11.2794 5.46238 11.4099 5.43238 11.5522L5.01758 13.5185L6.98394 13.1037C7.1262 13.0737 7.25666 13.003 7.35947 12.9002L12.9833 7.27639L11.2597 5.55281Z"
fill=""
/>
</svg>
{t("edit_button")}
</button>
</div>
</div>
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[700px] m-4">
<div className="no-scrollbar relative w-full max-w-[700px] overflow-y-auto rounded-3xl bg-white p-4 dark:bg-gray-900 lg:p-11">
<div className="px-2 pr-14">
<h4 className="mb-2 text-2xl font-semibold text-gray-800 dark:text-white/90">
{t("edit_metadata_title")}
</h4>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400 lg:mb-7">
{t("edit_metadata_subtitle")}
</p>
</div>
<form className="flex flex-col" onSubmit={handleSave}>
<div className="custom-scrollbar px-2 pb-3">
<div className="grid grid-cols-1 gap-x-6 gap-y-5 lg:grid-cols-2">
<div className="col-span-2 lg:col-span-1">
<Label>{t("job_title_label")}</Label>
<Input
type="text"
value={jobTitle}
onChange={(e) => setJobTitle(e.target.value)}
placeholder={t("job_title_placeholder")}
/>
</div>
<div className="col-span-2 lg:col-span-1">
<Label>{t("location_label")}</Label>
<Input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder={t("location_placeholder")}
/>
</div>
</div>
</div>
<div className="flex items-center gap-3 px-2 mt-6 lg:justify-end">
<Button size="sm" variant="outline" type="button" onClick={closeModal} disabled={isSaving}>
{t("close_button")}
</Button>
<Button size="sm" type="submit" loading={isSaving}>
{t("save_changes_button")}
</Button>
</div>
</form>
</div>
</Modal>
{selectedFile && (
<ImageCropper
image={selectedFile}
isOpen={isCropperOpen}
onClose={() => {
setIsCropperOpen(false);
setSelectedFile(null);
}}
onCropComplete={handleCropComplete}
/>
)}
</>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import React, { useState } from "react";
import axios from "@/lib/axios";
import Button from "../ui/button/Button";
import Input from "../form/input/InputField";
import Label from "../form/Label";
import { useToast } from "@/context/ToastContext";
import { useTranslations } from "next-intl";
export default function UserPasswordCard() {
const { addToast } = useToast();
const t = useTranslations("Profile");
const [isSaving, setIsSaving] = useState(false);
const [formData, setFormData] = useState({
current_password: "",
password: "",
password_confirmation: "",
});
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (formData.password !== formData.password_confirmation) {
addToast(t("toast_password_mismatch"), "error");
return;
}
setIsSaving(true);
try {
await axios.put("/api/profile/password", formData);
addToast(t("toast_password_success"), "success");
setFormData({
current_password: "",
password: "",
password_confirmation: "",
});
} catch (err: any) {
const message = err.response?.data?.message || t("toast_password_error");
addToast(message, "error");
} finally {
setIsSaving(false);
}
};
return (
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
<h4 className="mb-6 text-lg font-semibold text-gray-800 dark:text-white/90">
{t("update_password_title")}
</h4>
<form onSubmit={handleSave} className="space-y-5">
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2 lg:gap-6">
<div className="col-span-1 lg:col-span-2">
<Label>{t("current_password_label")}</Label>
<Input
type="password"
value={formData.current_password}
onChange={(e) => handleInputChange("current_password", e.target.value)}
placeholder={t("current_password_placeholder")}
required
/>
</div>
<div>
<Label>{t("new_password_label")}</Label>
<Input
type="password"
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
placeholder={t("new_password_placeholder")}
required
/>
</div>
<div>
<Label>{t("confirm_password_label")}</Label>
<Input
type="password"
value={formData.password_confirmation}
onChange={(e) => handleInputChange("password_confirmation", e.target.value)}
placeholder={t("confirm_password_placeholder")}
required
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" loading={isSaving}>
{t("update_password_title")}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,219 @@
"use client";
import React, { useState, useEffect } from "react";
import { Modal } from "../ui/modal";
import Button from "../ui/button/Button";
import { User as UserIcon, Mail, Lock, Shield, Phone, Info } from "lucide-react";
import { useTranslations } from "next-intl";
interface UserModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (data: any) => Promise<void>;
user?: any; // If provided, we are in Edit mode
isLoading?: boolean;
currentUser?: any;
}
const UserModal: React.FC<UserModalProps> = ({
isOpen,
onClose,
onSave,
user,
isLoading = false,
currentUser,
}) => {
const t = useTranslations("Users");
const [formData, setFormData] = useState({
first_name: "",
last_name: "",
email: "",
password: "",
role: "customer",
});
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (user) {
setFormData({
first_name: user.first_name || "",
last_name: user.last_name || "",
email: user.email || "",
password: "", // Never pre-fill password
role: user.role === 'user' ? 'customer' : (user.role || "customer"),
});
} else {
setFormData({
first_name: "",
last_name: "",
email: "",
password: "",
role: "customer",
});
}
setErrors({});
}, [user, isOpen]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
if (errors[name]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const validate = () => {
const newErrors: Record<string, string> = {};
if (!formData.first_name) newErrors.first_name = t("err_first_name_req");
if (!formData.email) newErrors.email = t("err_email_req");
else if (!/\S+@\S+\.\S+/.test(formData.email)) newErrors.email = t("err_email_invalid");
if (!user && !formData.password) {
newErrors.password = t("err_password_req");
} else if (formData.password && formData.password.length < 8) {
newErrors.password = t("err_password_min");
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (validate()) {
await onSave(formData);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} className="max-w-[500px]">
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
{user ? t("modal_edit_title") : t("modal_add_title")}
</h3>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">{t("first_name_label")}</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<UserIcon className="w-4 h-4" />
</span>
<input
type="text"
name="first_name"
value={formData.first_name}
onChange={handleChange}
placeholder="John"
className={`w-full pl-10 pr-4 py-2.5 text-sm border ${errors.first_name ? 'border-red-500' : 'border-gray-200 dark:border-gray-800'} rounded-xl bg-transparent focus:ring-2 focus:ring-brand-500/10 outline-none transition-all dark:text-white/90 dark:placeholder:text-white/30`}
/>
</div>
{errors.first_name && <p className="text-[10px] text-red-500 font-medium">{errors.first_name}</p>}
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">{t("last_name_label")}</label>
<input
type="text"
name="last_name"
value={formData.last_name}
onChange={handleChange}
placeholder="Doe"
className="w-full px-4 py-2.5 text-sm border border-gray-200 dark:border-gray-800 rounded-xl bg-transparent focus:ring-2 focus:ring-brand-500/10 outline-none transition-all dark:text-white/90 dark:placeholder:text-white/30"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">{t("email_label")}</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<Mail className="w-4 h-4" />
</span>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="john@example.com"
className={`w-full pl-10 pr-4 py-2.5 text-sm border ${errors.email ? 'border-red-500' : 'border-gray-200 dark:border-gray-800'} rounded-xl bg-transparent focus:ring-2 focus:ring-brand-500/10 outline-none transition-all dark:text-white/90 dark:placeholder:text-white/30`}
/>
</div>
{errors.email && <p className="text-[10px] text-red-500 font-medium">{errors.email}</p>}
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">
{user ? t("password_edit_label") : t("password_label")}
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<Lock className="w-4 h-4" />
</span>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="••••••••"
className={`w-full pl-10 pr-4 py-2.5 text-sm border ${errors.password ? 'border-red-500' : 'border-gray-200 dark:border-gray-800'} rounded-xl bg-transparent focus:ring-2 focus:ring-brand-500/10 outline-none transition-all dark:text-white/90 dark:placeholder:text-white/30`}
/>
</div>
{errors.password && <p className="text-[10px] text-red-500 font-medium">{errors.password}</p>}
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">{t("role_label")}</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<Shield className="w-4 h-4" />
</span>
<select
name="role"
value={formData.role}
onChange={handleChange}
disabled={currentUser?.role !== 'owner'}
className="w-full pl-10 pr-4 py-2.5 text-sm border border-gray-200 dark:border-gray-800 rounded-xl bg-white dark:bg-gray-900 focus:ring-2 focus:ring-brand-500/10 outline-none transition-all appearance-none dark:text-white/90 disabled:opacity-50 cursor-pointer"
>
<option value="customer" className="bg-white dark:bg-gray-900">{t("role_customer_desc")}</option>
{currentUser?.role === 'owner' && (
<>
<option value="admin" className="bg-white dark:bg-gray-900">{t("role_admin_desc")}</option>
<option value="owner" className="bg-white dark:bg-gray-900">Owner (Super Admin)</option>
</>
)}
</select>
</div>
</div>
<div className="pt-4 flex items-center gap-3">
<Button
type="button"
variant="outline"
className="flex-1 rounded-xl"
onClick={onClose}
disabled={isLoading}
>
{t("cancel")}
</Button>
<Button
type="submit"
variant="primary"
className="flex-1 rounded-xl"
loading={isLoading}
>
{user ? t("update_user") : t("create_user")}
</Button>
</div>
</form>
</Modal>
);
};
export default UserModal;

View File

@@ -0,0 +1,16 @@
import React from "react";
export default function FourIsToThree() {
return (
<div className="aspect-4/3 overflow-hidden rounded-lg">
<iframe
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
title="YouTube video"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full"
></iframe>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import React from "react";
export default function OneIsToOne() {
return (
<div className="overflow-hidden rounded-lg aspect-square">
<iframe
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
title="YouTube video"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full"
></iframe>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import React from "react";
export default function SixteenIsToNine() {
return (
<div className="aspect-4/3 overflow-hidden rounded-lg">
<iframe
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
title="YouTube video"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full"
></iframe>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import React from "react";
export default function TwentyOneIsToNine() {
return (
<div className="aspect-21/9 overflow-hidden rounded-lg">
<iframe
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
title="YouTube video"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full"
></iframe>
</div>
);
}