mirror of
https://github.com/dyzulk/trustlab.git
synced 2026-01-26 13:32:06 +07:00
First commit
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import GridShape from "@/components/common/GridShape";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Error404Client() {
|
||||
const t = useTranslations("Error");
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center min-h-screen p-6 overflow-hidden z-1">
|
||||
<GridShape />
|
||||
<div className="mx-auto w-full max-w-[242px] text-center sm:max-w-[472px]">
|
||||
<h1 className="mb-8 font-bold text-gray-800 text-title-md dark:text-white/90 xl:text-title-2xl">
|
||||
{t('title_404')}
|
||||
</h1>
|
||||
|
||||
<Image
|
||||
src="/images/error/404.svg"
|
||||
alt="404"
|
||||
className="dark:hidden"
|
||||
width={472}
|
||||
height={152}
|
||||
/>
|
||||
<Image
|
||||
src="/images/error/404-dark.svg"
|
||||
alt="404"
|
||||
className="hidden dark:block"
|
||||
width={472}
|
||||
height={152}
|
||||
/>
|
||||
|
||||
<p className="mt-10 mb-6 text-base text-gray-700 dark:text-gray-400 sm:text-lg">
|
||||
{t('desc_404')}
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-5 py-3.5 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"
|
||||
>
|
||||
{t('back_to_home')}
|
||||
</Link>
|
||||
</div>
|
||||
{/* <!-- Footer --> */}
|
||||
<p className="absolute text-sm text-center text-gray-500 -translate-x-1/2 bottom-6 left-1/2 dark:text-gray-400">
|
||||
© {new Date().getFullYear()} - TrustLab
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/(full-width-pages)/(error-pages)/error-404/page.tsx
Normal file
11
src/app/(full-width-pages)/(error-pages)/error-404/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import Error404Client from "./Error404Client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Page Not Found",
|
||||
description: "The page you are looking for does not exist.",
|
||||
};
|
||||
|
||||
export default function Error404() {
|
||||
return <Error404Client />;
|
||||
}
|
||||
7
src/app/(full-width-pages)/layout.tsx
Normal file
7
src/app/(full-width-pages)/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function FullWidthPageLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
399
src/app/(public)/HomeClient.tsx
Normal file
399
src/app/(public)/HomeClient.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import CommonGridShape from "@/components/common/CommonGridShape";
|
||||
import { useEffect, useState } from "react";
|
||||
import axios from '@/lib/axios';
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface CaCertificate {
|
||||
name: string;
|
||||
type: string;
|
||||
serial: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
// Simple internal ScrollToTop component
|
||||
function ScrollToTop() {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setShow(window.pageYOffset > 500);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
const element = document.querySelector('#home');
|
||||
if (element) {
|
||||
const navbarOffset = 80;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - navbarOffset;
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-8 right-8 z-50 p-4 bg-brand-500 hover:bg-brand-600 text-white rounded-2xl shadow-2xl shadow-brand-500/40 transition-all hover:-translate-y-1 active:scale-95 animate-in fade-in slide-in-from-bottom-5"
|
||||
aria-label="Back to top"
|
||||
>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeClient() {
|
||||
const t = useTranslations("Home");
|
||||
const [certificates, setCertificates] = useState<CaCertificate[]>([]);
|
||||
const [loadingCerts, setLoadingCerts] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCertificates = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/public/ca-certificates');
|
||||
if (response.data.success) {
|
||||
setCertificates(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch certificates", error);
|
||||
} finally {
|
||||
setLoadingCerts(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCertificates();
|
||||
}, []);
|
||||
|
||||
const handleScroll = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(id);
|
||||
if (element) {
|
||||
const navbarOffset = 80;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - navbarOffset;
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="relative flex-grow flex flex-col">
|
||||
{/* Hero Section */}
|
||||
<header className="relative pt-32 pb-20 overflow-hidden" id="home">
|
||||
{/* Background Shapes */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-7xl h-full -z-10 opacity-30 dark:opacity-20 pointer-events-none">
|
||||
<div className="absolute top-20 left-10 w-72 h-72 bg-brand-500 rounded-full blur-[120px]"></div>
|
||||
<div className="absolute bottom-10 right-10 w-96 h-96 bg-blue-500 rounded-full blur-[150px]"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-brand-50 dark:bg-brand-500/10 border border-brand-100 dark:border-brand-500/20 text-brand-600 dark:text-brand-400 text-xs font-bold uppercase tracking-widest mb-8 animate-bounce">
|
||||
🚀 {t('hero_tag')}
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-7xl font-extrabold text-gray-900 dark:text-white mb-6 leading-tight">
|
||||
{t('hero_title_1')} <br/>
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-500 to-blue-600">
|
||||
{t('hero_title_2')}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto mb-10">
|
||||
{t('hero_desc')}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link href="/signup" className="w-full sm:w-auto px-8 py-4 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-2xl font-bold shadow-xl transition-all hover:-translate-y-1 hover:shadow-2xl">
|
||||
{t('cta_create_account')}
|
||||
</Link>
|
||||
<a href="#features" onClick={(e) => handleScroll(e, '#features')} className="w-full sm:w-auto px-8 py-4 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 rounded-2xl font-bold transition-all hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
|
||||
{t('cta_explore_features')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Preview/Abstract UI */}
|
||||
<div className="mt-20 relative mx-auto max-w-5xl">
|
||||
<div className="aspect-video bg-white dark:bg-gray-800 rounded-3xl border border-gray-200 dark:border-gray-700 shadow-2xl p-4 overflow-hidden group">
|
||||
<div className="flex items-center gap-2 mb-4 border-b border-gray-100 dark:border-gray-700 pb-3">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-green-400"></div>
|
||||
</div>
|
||||
<div className="flex-1 ml-4 h-6 bg-gray-100 dark:bg-gray-900/50 rounded-lg max-w-xs"></div>
|
||||
</div>
|
||||
{/* Mock Dashboard Content */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<div className="h-40 bg-brand-500/5 rounded-2xl border border-brand-500/10"></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="h-24 bg-gray-50 dark:bg-gray-900/50 rounded-2xl"></div>
|
||||
<div className="h-24 bg-gray-50 dark:bg-gray-900/50 rounded-2xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="h-full bg-gray-50 dark:bg-gray-900/50 rounded-2xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Overlay Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white dark:from-gray-900 via-transparent to-transparent pointer-events-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Features Section */}
|
||||
<section id="features" className="py-24 bg-gray-50 dark:bg-gray-900/50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">{t('features_title')}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('features_desc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Feature 1 */}
|
||||
<div className="bg-white dark:bg-gray-800 p-8 rounded-3xl border border-gray-100 dark:border-gray-700 shadow-sm hover:shadow-xl transition-all duration-300 group">
|
||||
<div className="w-14 h-14 bg-brand-50 dark:bg-brand-500/10 rounded-2xl flex items-center justify-center text-brand-500 mb-6 group-hover:scale-110 transition-transform">
|
||||
<svg className="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-3">{t('feature_1_title')}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm leading-relaxed">
|
||||
{t('feature_1_desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 2 */}
|
||||
<div className="bg-white dark:bg-gray-800 p-8 rounded-3xl border border-gray-100 dark:border-gray-700 shadow-sm hover:shadow-xl transition-all duration-300 group">
|
||||
<div className="w-14 h-14 bg-blue-50 dark:bg-blue-500/10 rounded-2xl flex items-center justify-center text-blue-500 mb-6 group-hover:scale-110 transition-transform">
|
||||
<svg className="h-7 w-7" 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.536 11 9 13.536 7.464 12 4.929 14.536V17h2.472l4.243-4.243a6 6 0 018.828-5.743zM16.5 13.5V18h6v-4.5h-6z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-3">{t('feature_2_title')}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm leading-relaxed">
|
||||
{t('feature_2_desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 3 */}
|
||||
<div className="bg-white dark:bg-gray-800 p-8 rounded-3xl border border-gray-100 dark:border-gray-700 shadow-sm hover:shadow-xl transition-all duration-300 group">
|
||||
<div className="w-14 h-14 bg-green-50 dark:bg-green-500/10 rounded-2xl flex items-center justify-center text-green-500 mb-6 group-hover:scale-110 transition-transform">
|
||||
<svg className="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-3">{t('feature_3_title')}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm leading-relaxed">
|
||||
{t('feature_3_desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Trust Store Section */}
|
||||
<section id="trust-store" className="py-24 bg-white dark:bg-gray-800 relative overflow-hidden">
|
||||
{/* Gradient Background */}
|
||||
<div className="absolute inset-0 opacity-30 dark:opacity-10 pointer-events-none">
|
||||
<div className="absolute -top-20 -right-20 w-96 h-96 bg-brand-500/20 rounded-full blur-[100px]"></div>
|
||||
<div className="absolute -bottom-20 -left-20 w-80 h-80 bg-blue-500/20 rounded-full blur-[100px]"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">{t('trust_store_title')}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
{t('trust_store_desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loadingCerts ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-brand-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Root CA - Centered */}
|
||||
<div className="flex flex-col items-center gap-8 mb-8">
|
||||
{certificates.filter(c => c.type === 'root').map((cert) => (
|
||||
<div key={cert.serial} className="w-full max-w-lg bg-gray-50 dark:bg-gray-900/50 rounded-3xl p-8 border border-gray-100 dark:border-gray-700 hover:shadow-xl transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<span className={`inline-block px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-3 bg-purple-100 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400`}>
|
||||
{t('root_ca')}
|
||||
</span>
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">{cert.name}</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 font-mono">{t('serial')}: {cert.serial}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-white dark:bg-gray-800 rounded-2xl flex items-center justify-center text-gray-400 shadow-sm">
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/public/ca-certificates/${cert.serial}/download`}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 rounded-xl border border-gray-200 dark:border-gray-700 font-medium text-sm hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
title={t('download_standard_title')}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{t('download_standard')}
|
||||
</a>
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/public/ca-certificates/${cert.serial}/download?format=der`}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-green-50 dark:bg-green-500/10 text-green-700 dark:text-green-400 rounded-xl border border-green-200 dark:border-green-500/20 font-medium text-sm hover:bg-green-100 dark:hover:bg-green-500/20 transition-colors"
|
||||
title={t('download_android_title')}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.523 15.3414C17.523 16.7113 16.4805 17.7719 15.1743 17.7719C13.8681 17.7719 12.8257 16.7113 12.8257 15.3414C12.8257 13.9715 13.8681 12.9109 15.1743 12.9109C16.4805 12.9109 17.523 13.9715 17.523 15.3414ZM11.1714 15.3414C11.1714 16.7113 10.1289 17.7719 8.82276 17.7719C7.51659 17.7719 6.47412 16.7113 6.47412 15.3414C6.47412 13.9715 7.51659 12.9109 8.82276 12.9109C10.1289 12.9109 11.1714 13.9715 11.1714 15.3414ZM16.3262 5.86762L17.7119 3.44754C17.7981 3.29806 17.7513 3.10499 17.5932 3.01894C17.4391 2.92983 17.2505 2.97372 17.152 3.1232L15.7251 5.61793C14.0754 4.86961 12.1956 4.86961 10.5982 5.5645L9.1713 3.06977C9.0768 2.92028 8.88412 2.87234 8.73004 2.9654C8.5719 3.05144 8.52504 3.24451 8.6112 3.394L9.99708 5.8143C6.31383 7.82084 3.86221 11.6968 3.86221 16.0359H20.1378C20.1378 11.6968 17.6862 7.82084 16.3262 5.86762Z"/>
|
||||
</svg>
|
||||
{t('download_android')}
|
||||
</a>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/public/ca-certificates/${cert.serial}/download/windows`}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 rounded-xl border border-blue-200 dark:border-blue-500/20 font-medium text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors"
|
||||
title={t('download_windows_title')}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M0 3.449L9.75 2.134V11.3L0 11.3V3.449ZM9.75 12.7L0 12.7V20.551L9.75 19.166V12.7ZM10.5 1.998L24 0.166V11.3L10.5 11.3V1.998ZM10.5 12.7L24 12.7V23.834L10.5 21.966V12.7Z"/>
|
||||
</svg>
|
||||
{t('download_windows')}
|
||||
</a>
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/public/ca-certificates/${cert.serial}/download/mac`}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded-xl border border-gray-200 dark:border-gray-600 font-medium text-sm hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
title={t('download_macos_title')}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.21-1.96 1.07-3.11-1.05.05-2.31.74-3.03 1.59-.65.77-1.2 2.02-1.07 3.12 1.17.09 2.36-.73 3.03-1.6"/>
|
||||
</svg>
|
||||
{t('download_macos')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Intermediate CAs - Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-5xl mx-auto">
|
||||
{certificates.filter(c => c.type !== 'root').map((cert) => (
|
||||
<div key={cert.serial} className="bg-gray-50 dark:bg-gray-900/50 rounded-3xl p-8 border border-gray-100 dark:border-gray-700 hover:shadow-xl transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<span className={`inline-block px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-3 bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400`}>
|
||||
{t('intermediate_ca')}
|
||||
</span>
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">{cert.name}</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 font-mono">{t('serial')}: {cert.serial}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-white dark:bg-gray-800 rounded-2xl flex items-center justify-center text-gray-400 shadow-sm">
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/public/ca-certificates/${cert.serial}/download`}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 rounded-xl border border-gray-200 dark:border-gray-700 font-medium text-sm hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
title={t('download_standard_title')}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{t('download_standard')}
|
||||
</a>
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/public/ca-certificates/${cert.serial}/download?format=der`}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-green-50 dark:bg-green-500/10 text-green-700 dark:text-green-400 rounded-xl border border-green-200 dark:border-green-500/20 font-medium text-sm hover:bg-green-100 dark:hover:bg-green-500/20 transition-colors"
|
||||
title={t('download_android_title')}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.523 15.3414C17.523 16.7113 16.4805 17.7719 15.1743 17.7719C13.8681 17.7719 12.8257 16.7113 12.8257 15.3414C12.8257 13.9715 13.8681 12.9109 15.1743 12.9109C16.4805 12.9109 17.523 13.9715 17.523 15.3414ZM11.1714 15.3414C11.1714 16.7113 10.1289 17.7719 8.82276 17.7719C7.51659 17.7719 6.47412 16.7113 6.47412 15.3414C6.47412 13.9715 7.51659 12.9109 8.82276 12.9109C10.1289 12.9109 11.1714 13.9715 11.1714 15.3414ZM16.3262 5.86762L17.7119 3.44754C17.7981 3.29806 17.7513 3.10499 17.5932 3.01894C17.4391 2.92983 17.2505 2.97372 17.152 3.1232L15.7251 5.61793C14.0754 4.86961 12.1956 4.86961 10.5982 5.5645L9.1713 3.06977C9.0768 2.92028 8.88412 2.87234 8.73004 2.9654C8.5719 3.05144 8.52504 3.24451 8.6112 3.394L9.99708 5.8143C6.31383 7.82084 3.86221 11.6968 3.86221 16.0359H20.1378C20.1378 11.6968 17.6862 7.82084 16.3262 5.86762Z"/>
|
||||
</svg>
|
||||
{t('download_android')}
|
||||
</a>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/public/ca-certificates/${cert.serial}/download/windows`}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 rounded-xl border border-blue-200 dark:border-blue-500/20 font-medium text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors"
|
||||
title={t('download_windows_title')}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M0 3.449L9.75 2.134V11.3L0 11.3V3.449ZM9.75 12.7L0 12.7V20.551L9.75 19.166V12.7ZM10.5 1.998L24 0.166V11.3L10.5 11.3V1.998ZM10.5 12.7L24 12.7V23.834L10.5 21.966V12.7Z"/>
|
||||
</svg>
|
||||
{t('download_windows')}
|
||||
</a>
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/public/ca-certificates/${cert.serial}/download/mac`}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded-xl border border-gray-200 dark:border-gray-600 font-medium text-sm hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
title={t('download_macos_title')}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.21-1.96 1.07-3.11-1.05.05-2.31.74-3.03 1.59-.65.77-1.2 2.02-1.07 3.12 1.17.09 2.36-.73 3.03-1.6"/>
|
||||
</svg>
|
||||
{t('download_macos')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-brand-600 rounded-[3rem] p-12 md:p-16 text-center text-white relative overflow-hidden shadow-2xl">
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-6">{t('cta_ready_title')}</h2>
|
||||
<p className="text-brand-100 mb-10 max-w-lg mx-auto">{t('cta_ready_desc')}</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link href="/signup" className="px-8 py-4 bg-white text-brand-600 rounded-2xl font-bold hover:scale-105 transition-transform">
|
||||
{t('cta_free_account')}
|
||||
</Link>
|
||||
<Link href="/signin" className="px-8 py-4 bg-brand-700 text-white rounded-2xl font-bold hover:bg-brand-800 transition-colors">
|
||||
{t('cta_signin_portal')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{/* Abstract Design */}
|
||||
<div className="absolute top-0 left-0 w-full h-full opacity-10 pointer-events-none">
|
||||
<CommonGridShape />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Back to Top Button */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/app/(public)/auth/callback/page.tsx
Normal file
111
src/app/(public)/auth/callback/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import CommonGridShape from "@/components/common/CommonGridShape";
|
||||
|
||||
function AuthCallbackContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [status, setStatus] = useState("Processing...");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get("token");
|
||||
const errorParam = searchParams.get("error");
|
||||
const action = searchParams.get("action");
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
|
||||
// Mirror Callback: If we receive an OAuth code directly from the provider,
|
||||
// proxy it to the backend for processing.
|
||||
if (code && !token) {
|
||||
const provider = searchParams.get("provider") || 'google';
|
||||
const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/auth/${provider}/callback?${searchParams.toString()}`;
|
||||
window.location.href = backendUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorParam) {
|
||||
if (errorParam === 'account_exists_please_login') {
|
||||
setError("This email is already associated with an account. Please sign in with your password and link your social account in settings.");
|
||||
} else if (errorParam === 'account_not_found_please_signup') {
|
||||
setError("Account not found. Please Sign Up to create a new account.");
|
||||
} else if (errorParam === 'authentication_failed') {
|
||||
setError("Authentication failed. Please try again.");
|
||||
} else {
|
||||
setError("An unknown error occurred during authentication.");
|
||||
}
|
||||
setStatus("");
|
||||
return;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
if (action === "set_password") {
|
||||
router.push("/auth/set-password?token=" + token);
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
} else {
|
||||
const timeout = setTimeout(() => {
|
||||
setError("No response from server.");
|
||||
setStatus("");
|
||||
}, 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
return (
|
||||
<div className="relative z-1 bg-white p-6 sm:p-0 dark:bg-gray-900 flex-grow flex items-center justify-center min-h-screen">
|
||||
<div className="relative z-10 w-full max-w-md p-8 text-center">
|
||||
{error ? (
|
||||
<div className="space-y-6">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/20">
|
||||
<svg className="h-8 w-8 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Authentication Error</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{error}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/signin"
|
||||
className="inline-flex w-full justify-center rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600"
|
||||
>
|
||||
Back to Sign In
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-brand-100 dark:bg-brand-900/20">
|
||||
<svg className="animate-spin h-8 w-8 text-brand-600 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>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">{status}</h2>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<CommonGridShape />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthCallback() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex h-screen items-center justify-center bg-white dark:bg-gray-900">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-brand-500 border-t-transparent"></div>
|
||||
</div>
|
||||
}>
|
||||
<AuthCallbackContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
151
src/app/(public)/auth/set-password/SetPasswordClient.tsx
Normal file
151
src/app/(public)/auth/set-password/SetPasswordClient.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import CommonGridShape from "@/components/common/CommonGridShape";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import axios from "@/lib/axios";
|
||||
|
||||
export default function SetPasswordClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
// If token is present in URL (passed from callback), we might need to use it
|
||||
// But usually, the session cookie is already set by the backend login.
|
||||
// The token query param is mostly for stateless clients, but we can store it if needed.
|
||||
// Ideally, Sanctum uses cookies.
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirmation, setPasswordConfirmation] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<any>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const submitForm = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!token) {
|
||||
setErrors({ general: "Authentication token is missing. Please try signing in again." });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors([]);
|
||||
|
||||
try {
|
||||
await axios.post("/api/auth/set-password", {
|
||||
password,
|
||||
password_confirmation: passwordConfirmation,
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Success
|
||||
router.push("/dashboard?success=password_set");
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 422) {
|
||||
setErrors(error.response.data.errors);
|
||||
} else {
|
||||
setErrors({ general: "Something went wrong. Please try again." });
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative z-1 bg-white p-6 sm:p-0 dark:bg-gray-900 flex-grow flex">
|
||||
{/* Form Section */}
|
||||
<div className="flex w-full flex-1 flex-col lg:w-1/2 justify-center">
|
||||
<div className="mx-auto w-full max-w-md pt-5 sm:py-10 px-4 sm:px-0">
|
||||
|
||||
<div className="mb-5 sm:mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-semibold text-gray-800 dark:text-white/90 mb-2">
|
||||
Set Password
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Since you signed up via social login, please set a password for your account security.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submitForm}>
|
||||
<div className="space-y-5">
|
||||
{errors.general && (
|
||||
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-lg dark:bg-red-900/10 mb-4">
|
||||
{errors.general}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
New Password<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="dark:bg-gray-900 shadow-sm focus:border-brand-300 focus:ring-brand-500/10 dark:focus:border-brand-800 h-11 w-full rounded-lg border border-gray-300 bg-transparent py-2.5 pr-11 pl-4 text-sm text-gray-800 placeholder:text-gray-400 focus:ring-3 focus:outline-hidden dark:border-gray-700 dark:text-white/90 dark:placeholder:text-white/30"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute top-1/2 right-4 z-30 -translate-y-1/2 cursor-pointer text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="mt-1 text-xs text-red-500">{errors.password[0]}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
Confirm Password<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordConfirmation}
|
||||
onChange={(e) => setPasswordConfirmation(e.target.value)}
|
||||
className="dark:bg-gray-900 shadow-sm focus:border-brand-300 focus:ring-brand-500/10 dark:focus:border-brand-800 h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:ring-3 focus:outline-hidden dark:border-gray-700 dark:text-white/90 dark:placeholder:text-white/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="bg-brand-500 shadow-md hover:bg-brand-600 w-full rounded-lg px-4 py-3 text-sm font-medium text-white transition disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Setting Password...' : 'Set Password & Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side Branding & Background */}
|
||||
<div className="relative hidden w-full items-center justify-center lg:flex lg:w-1/2 overflow-hidden bg-brand-950 dark:bg-gray-900/50">
|
||||
<CommonGridShape />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-brand-500/10 rounded-full blur-[120px] pointer-events-none opacity-50"></div>
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center max-w-sm px-8 text-center">
|
||||
<Link href="/" className="mb-8 block transition-transform hover:scale-105 active:scale-95">
|
||||
<img src="/images/logo/auth-logo.svg" alt="TrustLab Logo" className="h-16 w-auto" />
|
||||
</Link>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">
|
||||
Secure Your Account
|
||||
</h2>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm leading-relaxed">
|
||||
Setting a password ensures you can always access your account, even if you lose access to your social login.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/app/(public)/auth/set-password/page.tsx
Normal file
20
src/app/(public)/auth/set-password/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Suspense } from "react";
|
||||
import { Metadata } from "next";
|
||||
import SetPasswordClient from "./SetPasswordClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Set Password",
|
||||
description: "Set your account password to complete registration.",
|
||||
};
|
||||
|
||||
export default function SetPassword() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex h-screen items-center justify-center bg-white dark:bg-gray-900">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-brand-500 border-t-transparent"></div>
|
||||
</div>
|
||||
}>
|
||||
<SetPasswordClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
183
src/app/(public)/contact/ContactClient.tsx
Normal file
183
src/app/(public)/contact/ContactClient.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import axios from "@/lib/axios";
|
||||
import Alert from "@/components/ui/alert/Alert";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
|
||||
export default function ContactClient() {
|
||||
const [alertState, setAlertState] = useState<{
|
||||
variant: "success" | "error";
|
||||
title: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [errors, setErrors] = useState<any>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
||||
const t = useTranslations("Public");
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
category: "Technical Support",
|
||||
subject: "",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
setAlertState(null);
|
||||
|
||||
try {
|
||||
await axios.post("/api/public/inquiries", formData);
|
||||
setAlertState({
|
||||
variant: 'success',
|
||||
title: t('success_title'),
|
||||
message: t('success_message'),
|
||||
});
|
||||
setFormData({
|
||||
name: "",
|
||||
email: "",
|
||||
category: "Technical Support",
|
||||
subject: "",
|
||||
message: "",
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.response && error.response.status === 422) {
|
||||
setErrors(error.response.data.errors);
|
||||
} else {
|
||||
setAlertState({
|
||||
variant: 'error',
|
||||
title: t('fail_title'),
|
||||
message: t('fail_message'),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-grow pt-32 pb-20 px-4 relative z-10 w-full flex flex-col">
|
||||
<div className="max-w-xl mx-auto space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-extrabold text-gray-900 dark:text-white mb-4">
|
||||
{t("contact_title")}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t("contact_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{alertState && (
|
||||
<Alert
|
||||
variant={alertState.variant}
|
||||
title={alertState.title}
|
||||
message={alertState.message}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="bg-white px-8 py-10 shadow-2xl rounded-[2.5rem] dark:bg-white/[0.03] border border-gray-100 dark:border-gray-800 backdrop-blur-sm">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-2">{t("name_label")}</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-2xl border-gray-200 bg-gray-50/50 px-5 py-4 text-sm text-gray-900 transition focus:ring-brand-500 focus:border-brand-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-[10px] text-red-500 font-bold uppercase">{errors.name[0]}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-2">{t("email_label")}</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-2xl border-gray-200 bg-gray-50/50 px-5 py-4 text-sm text-gray-900 transition focus:ring-brand-500 focus:border-brand-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
{errors.email && <p className="mt-1 text-[10px] text-red-500 font-bold uppercase">{errors.email[0]}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-2">{t("category_label")}</label>
|
||||
<select
|
||||
name="category"
|
||||
id="category"
|
||||
required
|
||||
value={formData.category}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-2xl border-gray-200 bg-white px-5 py-4 text-sm text-gray-900 transition focus:ring-brand-500 focus:border-brand-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="Technical Support" className="bg-white dark:bg-gray-800">{t("technical_support")}</option>
|
||||
<option value="Legal Inquiry" className="bg-white dark:bg-gray-800">{t("legal_inquiry")}</option>
|
||||
<option value="Partnership" className="bg-white dark:bg-gray-800">{t("partnership")}</option>
|
||||
<option value="Other" className="bg-white dark:bg-gray-800">{t("other")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-2">{t("subject_label")}</label>
|
||||
<input
|
||||
type="text"
|
||||
name="subject"
|
||||
id="subject"
|
||||
required
|
||||
value={formData.subject}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-2xl border-gray-200 bg-gray-50/50 px-5 py-4 text-sm text-gray-900 transition focus:ring-brand-500 focus:border-brand-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
{errors.subject && <p className="mt-1 text-[10px] text-red-500 font-bold uppercase">{errors.subject[0]}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-2">{t("message_label")}</label>
|
||||
<textarea
|
||||
name="message"
|
||||
id="message"
|
||||
rows={4}
|
||||
required
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-2xl border-gray-200 bg-gray-50/50 px-5 py-4 text-sm text-gray-900 transition focus:ring-brand-500 focus:border-brand-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white resize-none"
|
||||
></textarea>
|
||||
{errors.message && <p className="mt-1 text-[10px] text-red-500 font-bold uppercase">{errors.message[0]}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center py-4">
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ""}
|
||||
onSuccess={(token) => setTurnstileToken(token)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !turnstileToken}
|
||||
className="flex w-full justify-center rounded-2xl bg-brand-500 px-4 py-5 text-sm font-bold text-white shadow-xl shadow-brand-500/30 hover:bg-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-500 transition-all active:scale-[0.98] disabled:opacity-75 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? t("sending") : t("send_message")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/(public)/contact/page.tsx
Normal file
11
src/app/(public)/contact/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import ContactClient from "./ContactClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contact Us",
|
||||
description: "Get in touch with the TrustLab team for technical support, legal inquiries, or partnership opportunities.",
|
||||
};
|
||||
|
||||
export default function Contact() {
|
||||
return <ContactClient />;
|
||||
}
|
||||
106
src/app/(public)/forgot-password/ForgotPasswordClient.tsx
Normal file
106
src/app/(public)/forgot-password/ForgotPasswordClient.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useTranslations } from "next-intl";
|
||||
import CommonGridShape from "@/components/common/CommonGridShape";
|
||||
|
||||
export default function ForgotPasswordClient() {
|
||||
const t = useTranslations("Auth");
|
||||
const { forgotPassword } = useAuth({
|
||||
middleware: "guest",
|
||||
redirectIfAuthenticated: "/dashboard",
|
||||
});
|
||||
const [email, setEmail] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<any>([]);
|
||||
|
||||
const submitForm = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setMessage(null);
|
||||
setErrors([]);
|
||||
|
||||
forgotPassword({
|
||||
email,
|
||||
setStatus: setMessage,
|
||||
setErrors,
|
||||
}).finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative z-1 bg-white p-6 sm:p-0 dark:bg-gray-900 flex-grow flex">
|
||||
{/* Form Section */}
|
||||
<div className="flex w-full flex-1 flex-col lg:w-1/2 justify-center">
|
||||
<div className="mx-auto w-full max-w-md pt-5 sm:py-10 px-4 sm:px-0">
|
||||
<div className="mb-5 sm:mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-semibold text-gray-800 dark:text-white/90 mb-2">
|
||||
{t('forgot_password_title')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('forgot_password_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submitForm}>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
{t('email_label')}<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('email_placeholder')}
|
||||
className="dark:bg-gray-900 shadow-sm focus:border-brand-300 focus:ring-brand-500/10 dark:focus:border-brand-800 h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:ring-3 focus:outline-hidden dark:border-gray-700 dark:text-white/90 dark:placeholder:text-white/30"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && <div className="p-3 text-sm text-green-600 bg-green-50 rounded-lg">{message}</div>}
|
||||
{errors?.email && <div className="p-3 text-sm text-red-600 bg-red-50 rounded-lg">{errors.email}</div>}
|
||||
{errors?.message && <div className="p-3 text-sm text-red-600 bg-red-50 rounded-lg">{errors.message}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="bg-brand-500 shadow-md hover:bg-brand-600 w-full rounded-lg px-4 py-3 text-sm font-medium text-white transition disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? t('sending_link_button') : t('send_reset_link')}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/signin" className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
{t('back_to_signin')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side Branding & Background */}
|
||||
<div className="relative hidden w-full items-center justify-center lg:flex lg:w-1/2 overflow-hidden bg-brand-950 dark:bg-gray-900/50">
|
||||
<CommonGridShape />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-brand-500/10 rounded-full blur-[120px] pointer-events-none opacity-50"></div>
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center max-w-sm px-8 text-center">
|
||||
<Link href="/" className="mb-8 block transition-transform hover:scale-105 active:scale-95">
|
||||
<img src="/images/logo/auth-logo.svg" alt="TrustLab Logo" className="h-16 w-auto" />
|
||||
</Link>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">
|
||||
{t('recovery_title')}
|
||||
</h2>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm leading-relaxed">
|
||||
{t('recovery_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/(public)/forgot-password/page.tsx
Normal file
11
src/app/(public)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
import React, { Suspense } from 'react';
|
||||
import ForgotPasswordClient from "./ForgotPasswordClient";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<ForgotPasswordClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
7
src/app/(public)/layout.tsx
Normal file
7
src/app/(public)/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
import PublicLayout from "@/components/Layouts/PublicLayout";
|
||||
import React from "react";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <PublicLayout>{children}</PublicLayout>;
|
||||
}
|
||||
98
src/app/(public)/legal/page.tsx
Normal file
98
src/app/(public)/legal/page.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight, Scale, FileText } from 'lucide-react';
|
||||
import axios from '@/lib/axios';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface LegalPageItem {
|
||||
title: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export default function LegalIndexPage() {
|
||||
const t = useTranslations("Legal");
|
||||
const [pages, setPages] = useState<LegalPageItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPages = async () => {
|
||||
try {
|
||||
const { data } = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/api/public/legal-pages`);
|
||||
setPages(data.data as LegalPageItem[]);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch legal pages:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchPages();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 pb-20">
|
||||
{/* Hero Section */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 py-16 md:py-24 relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full opacity-30 dark:opacity-10 pointer-events-none bg-[radial-gradient(circle_at_top_right,_var(--tw-gradient-stops))] from-brand-100 via-transparent to-transparent"></div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 relative z-10 text-center">
|
||||
<div className="inline-flex items-center justify-center p-3 bg-brand-50 dark:bg-brand-500/10 rounded-2xl mb-6 text-brand-600 dark:text-brand-400">
|
||||
<Scale size={32} />
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-black text-gray-900 dark:text-white mb-6 tracking-tight">
|
||||
{t('center_title')}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
{t('center_desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="max-w-5xl mx-auto px-6 py-12 -mt-10 relative z-20">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{isLoading ? (
|
||||
<div className="col-span-full py-20 text-center">
|
||||
<div className="w-10 h-10 border-4 border-brand-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-gray-500">{t('loading_docs')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{pages.map((page) => (
|
||||
<Link
|
||||
key={page.slug}
|
||||
href={`/legal/view?slug=${page.slug}`}
|
||||
className="group bg-white dark:bg-gray-800 p-8 rounded-3xl border border-gray-100 dark:border-gray-700 shadow-sm hover:shadow-xl transition-all duration-300 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gray-50 dark:bg-gray-700 flex items-center justify-center text-gray-500 dark:text-gray-400 group-hover:bg-brand-50 dark:group-hover:bg-brand-500/20 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">
|
||||
<FileText size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">
|
||||
{page.title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('read_full')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-full bg-gray-50 dark:bg-gray-700 flex items-center justify-center text-gray-400 group-hover:bg-brand-600 group-hover:text-white transition-all transform group-hover:translate-x-1">
|
||||
<ArrowRight size={14} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{pages.length === 0 && (
|
||||
<div className="col-span-full text-center py-20 text-gray-500 bg-white dark:bg-gray-800 rounded-3xl border border-dashed border-gray-300 dark:border-gray-700">
|
||||
<p>{t('no_docs')}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
src/app/(public)/legal/view/page.tsx
Normal file
94
src/app/(public)/legal/view/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, Suspense } from 'react';
|
||||
import { notFound, useSearchParams } from 'next/navigation';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
function LegalPageContent() {
|
||||
const t = useTranslations("Legal");
|
||||
const searchParams = useSearchParams();
|
||||
const slug = searchParams.get('slug');
|
||||
const [page, setPage] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchPage = async () => {
|
||||
try {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
const res = await fetch(`${apiUrl}/api/public/legal-pages/${slug}`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setPage(json.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPage();
|
||||
}, [slug]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="w-10 h-10 border-4 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!page) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-3xl shadow-xl overflow-hidden border border-gray-100 dark:border-gray-800">
|
||||
<div className="p-8 md:p-12">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-800 pb-8 mb-8">
|
||||
<h1 className="text-3xl md:text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-brand-600 to-blue-600 dark:from-brand-400 dark:to-blue-400 mb-4">
|
||||
{page.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>{t('version')} {page.version}</span>
|
||||
<span>•</span>
|
||||
<span>{t('last_updated')}: {new Date(page.updated_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Marketing/Styled Content */}
|
||||
<article className="prose dark:prose-invert max-w-none
|
||||
prose-headings:font-bold prose-headings:tracking-tight
|
||||
prose-headings:!mt-1 prose-headings:!mb-0
|
||||
prose-a:text-brand-600 dark:prose-a:text-brand-400 hover:prose-a:text-brand-500
|
||||
prose-img:rounded-2xl prose-hr:border-gray-200 dark:prose-hr:border-gray-800
|
||||
prose-p:!leading-tight prose-p:!my-0
|
||||
prose-ul:!my-0 prose-ol:!my-0
|
||||
prose-li:!my-0 prose-li:!leading-tight [&_li_p]:!my-0
|
||||
whitespace-pre-wrap">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{page.content}</ReactMarkdown>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PublicLegalPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center min-h-[400px]"><div className="w-10 h-10 border-4 border-brand-500 border-t-transparent rounded-full animate-spin"></div></div>}>
|
||||
<LegalPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
11
src/app/(public)/page.tsx
Normal file
11
src/app/(public)/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import HomeClient from "./HomeClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Welcome to TrustLab",
|
||||
description: "Advanced Certificate Authority and PKI Management System. Issue and manage SSL/TLS certificates and API keys with ease.",
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
return <HomeClient />;
|
||||
}
|
||||
138
src/app/(public)/reset-password/ResetPasswordClient.tsx
Normal file
138
src/app/(public)/reset-password/ResetPasswordClient.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useTranslations } from "next-intl";
|
||||
import CommonGridShape from "@/components/common/CommonGridShape";
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ResetPasswordClient() {
|
||||
const t = useTranslations("Auth");
|
||||
const searchParams = useSearchParams();
|
||||
const { resetPassword } = useAuth({
|
||||
middleware: "guest",
|
||||
redirectIfAuthenticated: "/dashboard",
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
token: "",
|
||||
email: "",
|
||||
password: "",
|
||||
password_confirmation: "",
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<any>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
token: searchParams.get("token") || "",
|
||||
email: searchParams.get("email") || "",
|
||||
}));
|
||||
}, [searchParams]);
|
||||
|
||||
const submitForm = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setMessage(null);
|
||||
setErrors([]);
|
||||
|
||||
resetPassword({
|
||||
...formData,
|
||||
setStatus: setMessage,
|
||||
setErrors,
|
||||
}).finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative z-1 bg-white p-6 sm:p-0 dark:bg-gray-900 flex-grow flex">
|
||||
<div className="flex w-full flex-1 flex-col lg:w-1/2 justify-center">
|
||||
<div className="mx-auto w-full max-w-md pt-5 sm:py-10 px-4 sm:px-0">
|
||||
<div className="mb-5 sm:mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-semibold text-gray-800 dark:text-white/90 mb-2">
|
||||
Reset Password
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Please enter your new password below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submitForm}>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
New Password<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="********"
|
||||
className="dark:bg-gray-900 shadow-sm focus:border-brand-300 focus:ring-brand-500/10 dark:focus:border-brand-800 h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:ring-3 focus:outline-hidden dark:border-gray-700 dark:text-white/90 dark:placeholder:text-white/30"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
Confirm Password<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password_confirmation}
|
||||
onChange={(e) => setFormData({ ...formData, password_confirmation: e.target.value })}
|
||||
placeholder="********"
|
||||
className="dark:bg-gray-900 shadow-sm focus:border-brand-300 focus:ring-brand-500/10 dark:focus:border-brand-800 h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:ring-3 focus:outline-hidden dark:border-gray-700 dark:text-white/90 dark:placeholder:text-white/30"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && <div className="p-3 text-sm text-green-600 bg-green-50 rounded-lg">{message}</div>}
|
||||
{errors?.password && <div className="p-3 text-sm text-red-600 bg-red-50 rounded-lg">{errors.password}</div>}
|
||||
{errors?.message && <div className="p-3 text-sm text-red-600 bg-red-50 rounded-lg">{errors.message}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="bg-brand-500 shadow-md hover:bg-brand-600 w-full rounded-lg px-4 py-3 text-sm font-medium text-white transition disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? "Resetting..." : "Reset Password"}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/signin" className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
{t('back_to_signin')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative hidden w-full items-center justify-center lg:flex lg:w-1/2 overflow-hidden bg-brand-950 dark:bg-gray-900/50">
|
||||
<CommonGridShape />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-brand-500/10 rounded-full blur-[120px] pointer-events-none opacity-50"></div>
|
||||
<div className="relative z-10 flex flex-col items-center max-w-sm px-8 text-center">
|
||||
<Link href="/" className="mb-8 block transition-transform hover:scale-105 active:scale-95">
|
||||
<img
|
||||
src="/images/logo/auth-logo.svg"
|
||||
alt="TrustLab Logo"
|
||||
className="h-16 w-auto"
|
||||
/>
|
||||
</Link>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">
|
||||
Secure Your Account
|
||||
</h2>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm leading-relaxed">
|
||||
Resetting your password is a secure way to regain access to your certificates and API keys.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/app/(public)/reset-password/page.tsx
Normal file
10
src/app/(public)/reset-password/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import ResetPasswordClient from "@/app/(public)/reset-password/ResetPasswordClient";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<ResetPasswordClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
282
src/app/(public)/signin/SigninClient.tsx
Normal file
282
src/app/(public)/signin/SigninClient.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useTranslations } from "next-intl";
|
||||
import CommonGridShape from "@/components/common/CommonGridShape";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import axios from "@/lib/axios";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
|
||||
export default function SigninClient() {
|
||||
const t = useTranslations("Auth");
|
||||
const { login } = useAuth({
|
||||
middleware: "guest",
|
||||
redirectIfAuthenticated: "/dashboard",
|
||||
});
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [remember, setRemember] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<any>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
||||
|
||||
// 2FA State
|
||||
const [needs2FA, setNeeds2FA] = useState(false);
|
||||
const [twoFactorCode, setTwoFactorCode] = useState("");
|
||||
const [tempToken, setTempToken] = useState("");
|
||||
|
||||
const submitForm = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response: any = await login({
|
||||
email,
|
||||
password,
|
||||
remember,
|
||||
setErrors,
|
||||
});
|
||||
|
||||
if (response?.data?.two_factor_required) {
|
||||
setNeeds2FA(true);
|
||||
setTempToken(response.data.temp_token);
|
||||
}
|
||||
} catch (error) {
|
||||
// Error handled by useAuth
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit2FA = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await axios.post('/api/auth/2fa/verify', {
|
||||
code: twoFactorCode,
|
||||
remember: remember
|
||||
}, {
|
||||
headers: { Authorization: `Bearer ${tempToken}` }
|
||||
});
|
||||
// On success, standard useAuth flow or just reload/redirect
|
||||
window.location.href = "/dashboard";
|
||||
} catch (error: any) {
|
||||
setErrors({ code: error.response?.data?.message || 'Invalid code' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const socialLogin = (provider: string) => {
|
||||
window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/api/auth/${provider}/redirect?context=signin`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative z-1 bg-white p-6 sm:p-0 dark:bg-gray-900 flex-grow flex">
|
||||
{/* Form Section */}
|
||||
<div className="flex w-full flex-1 flex-col lg:w-1/2 justify-center">
|
||||
<div className="mx-auto w-full max-w-md pt-5 sm:py-10 px-4 sm:px-0">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
{/* Header elements removed as they are provided by Public Layout */}
|
||||
</div>
|
||||
|
||||
<div className="mb-5 sm:mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-semibold text-gray-800 dark:text-white/90 mb-2">
|
||||
{needs2FA ? t('two_factor_title') : t('signin_title')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{needs2FA ? t('two_factor_subtitle') : t('signin_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{needs2FA ? (
|
||||
<form onSubmit={submit2FA}>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
{t('two_factor_code_label')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={twoFactorCode}
|
||||
onChange={(e) => setTwoFactorCode(e.target.value)}
|
||||
placeholder={t('two_factor_code_placeholder')}
|
||||
maxLength={6}
|
||||
className="text-center tracking-widest text-lg dark:bg-gray-900 shadow-sm focus:border-brand-300 focus:ring-brand-500/10 dark:focus:border-brand-800 h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-gray-800 placeholder:text-gray-400 focus:ring-3 focus:outline-hidden dark:border-gray-700 dark:text-white/90 dark:placeholder:text-white/30"
|
||||
autoFocus
|
||||
/>
|
||||
{errors.code && <p className="mt-1 text-xs text-red-500">{errors.code}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex w-full justify-center rounded-2xl bg-gray-900 py-3.5 px-4 text-sm font-bold text-white transition-all hover:bg-gray-800 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-100 disabled:opacity-70"
|
||||
>
|
||||
{isLoading ? t('verifying_button') : t('verify_button')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNeeds2FA(false)}
|
||||
className="w-full text-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
{t('back_to_login_button')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 sm:gap-4 mb-5">
|
||||
<button
|
||||
onClick={() => socialLogin('google')}
|
||||
className="flex w-full items-center justify-center gap-3 rounded-2xl border border-gray-200 bg-white py-3.5 px-4 text-sm font-bold text-gray-700 transition-all hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.7511 10.1944C18.7511 9.47495 18.6915 8.94995 18.5626 8.40552H10.1797V11.6527H15.1003C15.0011 12.4597 14.4654 13.675 13.2749 14.4916L13.2582 14.6003L15.9087 16.6126L16.0924 16.6305C17.7788 15.1041 18.7511 12.8583 18.7511 10.1944Z" fill="#4285F4" />
|
||||
<path d="M10.1788 18.75C12.5895 18.75 14.6133 17.9722 16.0915 16.6305L13.274 14.4916C12.5201 15.0068 11.5081 15.3666 10.1788 15.3666C7.81773 15.3666 5.81379 13.8402 5.09944 11.7305L4.99473 11.7392L2.23868 13.8295L2.20264 13.9277C3.67087 16.786 6.68674 18.75 10.1788 18.75Z" fill="#34A853" />
|
||||
<path d="M5.10014 11.7305C4.91165 11.186 4.80257 10.6027 4.80257 9.99992C4.80257 9.3971 4.91165 8.81379 5.09022 8.26935L5.08523 8.1534L2.29464 6.02954L2.20333 6.0721C1.5982 7.25823 1.25098 8.5902 1.25098 9.99992C1.25098 11.4096 1.5982 12.7415 2.20333 13.9277L5.10014 11.7305Z" fill="#FBBC05" />
|
||||
<path d="M10.1789 4.63331C11.8554 4.63331 12.9864 5.34303 13.6312 5.93612L16.1511 3.525C14.6035 2.11528 12.5895 1.25 10.1789 1.25C6.68676 1.25 3.67088 3.21387 2.20264 6.07218L5.08953 8.26943C5.81381 6.15972 7.81776 4.63331 10.1789 4.63331Z" fill="#EB4335" />
|
||||
</svg>
|
||||
{t('google_button')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => socialLogin('github')}
|
||||
className="flex w-full items-center justify-center gap-3 rounded-2xl border border-gray-200 bg-white py-3.5 px-4 text-sm font-bold text-gray-700 transition-all hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12"/></svg>
|
||||
{t('github_button')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative py-3 sm:py-5 mb-5">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200 dark:border-gray-800"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white p-2 text-gray-400 sm:px-5 sm:py-2 dark:bg-gray-900">{t('or_text')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submitForm}>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
{t('email_label')}<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('email_placeholder')}
|
||||
className="dark:bg-gray-900 shadow-sm focus:border-brand-300 focus:ring-brand-500/10 dark:focus:border-brand-800 h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:ring-3 focus:outline-hidden dark:border-gray-700 dark:text-white/90 dark:placeholder:text-white/30"
|
||||
/>
|
||||
{errors.email && <p className="mt-1 text-xs text-red-500">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
{t('password_label')}<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('password_placeholder')}
|
||||
className="dark:bg-gray-900 shadow-sm focus:border-brand-300 focus:ring-brand-500/10 dark:focus:border-brand-800 h-11 w-full rounded-lg border border-gray-300 bg-transparent py-2.5 pr-11 pl-4 text-sm text-gray-800 placeholder:text-gray-400 focus:ring-3 focus:outline-hidden dark:border-gray-700 dark:text-white/90 dark:placeholder:text-white/30"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute top-1/2 right-4 z-30 -translate-y-1/2 cursor-pointer text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="mt-1 text-xs text-red-500">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex cursor-pointer items-center text-sm font-normal text-gray-700 select-none dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="mr-3 h-5 w-5 rounded-md border-gray-300 dark:border-gray-700 text-brand-500 focus:ring-brand-500"
|
||||
/>
|
||||
{t('remember_me')}
|
||||
</label>
|
||||
<Link href="/forgot-password" className="text-brand-500 hover:text-brand-600 dark:text-brand-400 text-sm">
|
||||
{t('forgot_password_link')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center py-2">
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ""}
|
||||
onSuccess={(token) => setTurnstileToken(token)}
|
||||
/>
|
||||
</div>
|
||||
{errors['cf-turnstile-response'] && <p className="mt-1 text-xs text-red-500">{errors['cf-turnstile-response']}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !turnstileToken}
|
||||
className="bg-brand-500 shadow-md hover:bg-brand-600 w-full rounded-lg px-4 py-3 text-sm font-medium text-white transition disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? t('signing_in_button') : t('signin_button')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-5">
|
||||
<p className="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('no_account')}{" "}
|
||||
<Link href="/signup" className="font-bold text-brand-500 hover:text-brand-600 dark:text-brand-400 dark:hover:text-brand-300">
|
||||
{t('signup_link')}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mt-4 text-center text-xs text-gray-500 sm:text-start dark:text-gray-500">
|
||||
{t('agree_to_text')} <Link href="/legal/view?slug=terms" className="underline hover:text-gray-700 dark:hover:text-gray-300">{t('terms_link')}</Link> {t('and_text')} <Link href="/legal/view?slug=privacy" className="underline hover:text-gray-700 dark:hover:text-gray-300">{t('privacy_policy_link')}</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side Branding & Background */}
|
||||
<div className="relative hidden w-full items-center justify-center lg:flex lg:w-1/2 overflow-hidden bg-brand-950 dark:bg-gray-900/50">
|
||||
{/* Background Decoration */}
|
||||
<CommonGridShape />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-brand-500/10 rounded-full blur-[120px] pointer-events-none opacity-50"></div>
|
||||
|
||||
{/* Branding Content */}
|
||||
<div className="relative z-10 flex flex-col items-center max-w-sm px-8 text-center">
|
||||
<Link href="/" className="mb-8 block transition-transform hover:scale-105 active:scale-95">
|
||||
<img src="/images/logo/auth-logo.svg" alt="TrustLab Logo" className="h-16 w-auto" />
|
||||
</Link>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">
|
||||
{t('welcome_back_title')}
|
||||
</h2>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm leading-relaxed">
|
||||
{t('welcome_back_desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Decorative element */}
|
||||
<div className="mt-12 flex gap-3">
|
||||
<div className="h-1.5 w-12 rounded-full bg-brand-500"></div>
|
||||
<div className="h-1.5 w-4 rounded-full bg-brand-500/30"></div>
|
||||
<div className="h-1.5 w-4 rounded-full bg-brand-500/30"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/(public)/signin/page.tsx
Normal file
11
src/app/(public)/signin/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import SigninClient from "./SigninClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign In",
|
||||
description: "Sign in to your TrustLab account to manage your certificates and API keys.",
|
||||
};
|
||||
|
||||
export default function Signin() {
|
||||
return <SigninClient />;
|
||||
}
|
||||
253
src/app/(public)/signup/SignupClient.tsx
Normal file
253
src/app/(public)/signup/SignupClient.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useTranslations } from "next-intl";
|
||||
import CommonGridShape from "@/components/common/CommonGridShape";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import axios from "@/lib/axios";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
|
||||
export default function SignupClient() {
|
||||
const t = useTranslations("Auth");
|
||||
const { login } = useAuth({
|
||||
middleware: "guest",
|
||||
redirectIfAuthenticated: "/dashboard",
|
||||
});
|
||||
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [terms, setTerms] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<any>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
||||
|
||||
const submitForm = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setErrors([]);
|
||||
|
||||
await axios.get("/sanctum/csrf-cookie");
|
||||
|
||||
axios
|
||||
.post("/register", {
|
||||
fname: firstName,
|
||||
lname: lastName,
|
||||
email,
|
||||
password,
|
||||
password_confirmation: password, // Depending on backend validation
|
||||
terms,
|
||||
})
|
||||
.then(() => {
|
||||
// Login user after successful registration
|
||||
login({ email, password, setErrors, remember: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.status === 422) {
|
||||
setErrors(error.response.data.errors);
|
||||
} else {
|
||||
setErrors({ email: ["Something went wrong. Please try again."] });
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const socialLogin = (provider: string) => {
|
||||
window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/api/auth/${provider}/redirect?context=signup`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative z-1 bg-white p-6 sm:p-0 dark:bg-gray-900 flex-grow flex">
|
||||
{/* Form Section */}
|
||||
<div className="flex w-full flex-1 flex-col lg:w-1/2 justify-center">
|
||||
<div className="mx-auto w-full max-w-md pt-5 sm:py-10 px-4 sm:px-0">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
{/* Header elements removed as they are provided by Public Layout */}
|
||||
</div>
|
||||
|
||||
<div className="mb-5 sm:mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-semibold text-gray-800 dark:text-white/90 mb-2">
|
||||
{t('signup_title')}
|
||||
</h1>
|
||||
<h2 className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('signup_subtitle')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 sm:gap-4 mb-5">
|
||||
<button
|
||||
onClick={() => socialLogin('google')}
|
||||
className="inline-flex items-center justify-center gap-3 rounded-lg bg-gray-100 px-4 py-3 text-sm font-normal text-gray-700 transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10 w-full whitespace-nowrap"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.7511 10.1944C18.7511 9.47495 18.6915 8.94995 18.5626 8.40552H10.1797V11.6527H15.1003C15.0011 12.4597 14.4654 13.675 13.2749 14.4916L13.2582 14.6003L15.9087 16.6126L16.0924 16.6305C17.7788 15.1041 18.7511 12.8583 18.7511 10.1944Z" fill="#4285F4" />
|
||||
<path d="M10.1788 18.75C12.5895 18.75 14.6133 17.9722 16.0915 16.6305L13.274 14.4916C12.5201 15.0068 11.5081 15.3666 10.1788 15.3666C7.81773 15.3666 5.81379 13.8402 5.09944 11.7305L4.99473 11.7392L2.23868 13.8295L2.20264 13.9277C3.67087 16.786 6.68674 18.75 10.1788 18.75Z" fill="#34A853" />
|
||||
<path d="M5.10014 11.7305C4.91165 11.186 4.80257 10.6027 4.80257 9.99992C4.80257 9.3971 4.91165 8.81379 5.09022 8.26935L5.08523 8.1534L2.29464 6.02954L2.20333 6.0721C1.5982 7.25823 1.25098 8.5902 1.25098 9.99992C1.25098 11.4096 1.5982 12.7415 2.20333 13.9277L5.10014 11.7305Z" fill="#FBBC05" />
|
||||
<path d="M10.1789 4.63331C11.8554 4.63331 12.9864 5.34303 13.6312 5.93612L16.1511 3.525C14.6035 2.11528 12.5895 1.25 10.1789 1.25C6.68676 1.25 3.67088 3.21387 2.20264 6.07218L5.08953 8.26943C5.81381 6.15972 7.81776 4.63331 10.1789 4.63331Z" fill="#EB4335" />
|
||||
</svg>
|
||||
{t('google_button')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => socialLogin('github')}
|
||||
className="inline-flex items-center justify-center gap-3 rounded-lg bg-gray-100 px-4 py-3 text-sm font-normal text-gray-700 transition-colors hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10 w-full whitespace-nowrap"
|
||||
>
|
||||
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
||||
{t('github_button')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative py-3 sm:py-5 mb-5">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200 dark:border-gray-800"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white p-2 text-gray-400 sm:px-5 sm:py-2 dark:bg-gray-900">{t('social_login_divider')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submitForm}>
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
{/* First Name */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
{t('first_name_label')}<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
placeholder={t('first_name_placeholder')}
|
||||
className="dark:bg-gray-900 shadow-sm focus:border-brand-300 focus:ring-brand-500/10 dark:focus:border-brand-800 h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:ring-3 focus:outline-hidden dark:border-gray-700 dark:text-white/90 dark:placeholder:text-white/30"
|
||||
/>
|
||||
{errors.fname && <p className="mt-1 text-xs text-red-500">{errors.fname}</p>}
|
||||
</div>
|
||||
{/* Last Name */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
{t('last_name_label')} <span className="text-gray-400 font-normal">{t('optional_text')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
placeholder={t('last_name_placeholder')}
|
||||
className="dark:bg-gray-900 shadow-sm focus:border-brand-300 focus:ring-brand-500/10 dark:focus:border-brand-800 h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:ring-3 focus:outline-hidden dark:border-gray-700 dark:text-white/90 dark:placeholder:text-white/30"
|
||||
/>
|
||||
{errors.lname && <p className="mt-1 text-xs text-red-500">{errors.lname}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
{t('email_label')}<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('email_placeholder')}
|
||||
className="dark:bg-gray-900 shadow-sm focus:border-brand-300 focus:ring-brand-500/10 dark:focus:border-brand-800 h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 placeholder:text-gray-400 focus:ring-3 focus:outline-hidden dark:border-gray-700 dark:text-white/90 dark:placeholder:text-white/30"
|
||||
/>
|
||||
{errors.email && <p className="mt-1 text-xs text-red-500">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
|
||||
{t('password_label')}<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('password_placeholder')}
|
||||
className="dark:bg-gray-900 shadow-sm focus:border-brand-300 focus:ring-brand-500/10 dark:focus:border-brand-800 h-11 w-full rounded-lg border border-gray-300 bg-transparent py-2.5 pr-11 pl-4 text-sm text-gray-800 placeholder:text-gray-400 focus:ring-3 focus:outline-hidden dark:border-gray-700 dark:text-white/90 dark:placeholder:text-white/30"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute top-1/2 right-4 z-30 -translate-y-1/2 cursor-pointer text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="mt-1 text-xs text-red-500">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex cursor-pointer items-start text-sm font-normal text-gray-700 select-none dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={terms}
|
||||
onChange={(e) => setTerms(e.target.checked)}
|
||||
className="mr-3 h-5 w-5 rounded-md border-gray-300 dark:border-gray-700 text-brand-500 focus:ring-brand-500"
|
||||
/>
|
||||
<p className="inline-block font-normal text-gray-500 dark:text-gray-400">
|
||||
{t('agree_to_signup_text')} <Link href="/legal/view?slug=terms" className="underline hover:text-gray-700 dark:hover:text-gray-300">{t('terms_link')}</Link> {t('and_text')} <Link href="/legal/view?slug=privacy" className="underline hover:text-gray-700 dark:hover:text-gray-300">{t('privacy_policy_link')}</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{errors.terms && <p className="mt-1 text-xs text-red-500">{errors.terms}</p>}
|
||||
|
||||
<div className="flex justify-center py-2">
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ""}
|
||||
onSuccess={(token) => setTurnstileToken(token)}
|
||||
/>
|
||||
</div>
|
||||
{errors['cf-turnstile-response'] && <p className="mt-1 text-xs text-red-500">{errors['cf-turnstile-response']}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !turnstileToken}
|
||||
className="bg-brand-500 shadow-md hover:bg-brand-600 w-full rounded-lg px-4 py-3 text-sm font-medium text-white transition disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? t('creating_account_button') : t('signup_button')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-5">
|
||||
<p className="text-center text-sm font-normal text-gray-700 sm:text-start dark:text-gray-400">
|
||||
{t('already_have_account')} <Link href="/signin" className="text-brand-500 hover:text-brand-600 dark:text-brand-400">{t('signin_link')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side Branding & Background */}
|
||||
<div className="relative hidden w-full items-center justify-center lg:flex lg:w-1/2 overflow-hidden bg-brand-950 dark:bg-gray-900/50">
|
||||
{/* Background Decoration */}
|
||||
<CommonGridShape />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-brand-500/10 rounded-full blur-[120px] pointer-events-none opacity-50"></div>
|
||||
|
||||
{/* Branding Content */}
|
||||
<div className="relative z-10 flex flex-col items-center max-w-sm px-8 text-center">
|
||||
<Link href="/" className="mb-8 block transition-transform hover:scale-105 active:scale-95">
|
||||
<img src="/images/logo/auth-logo.svg" alt="TrustLab Logo" className="h-16 w-auto" />
|
||||
</Link>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">
|
||||
{t('join_title')}
|
||||
</h2>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm leading-relaxed">
|
||||
{t('join_desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Decorative element */}
|
||||
<div className="mt-12 flex gap-3">
|
||||
<div className="h-1.5 w-4 rounded-full bg-brand-500/30"></div>
|
||||
<div className="h-1.5 w-12 rounded-full bg-brand-500"></div>
|
||||
<div className="h-1.5 w-4 rounded-full bg-brand-500/30"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/(public)/signup/page.tsx
Normal file
11
src/app/(public)/signup/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import SignupClient from "./SignupClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Account",
|
||||
description: "Join TrustLab to start issuing and managing your certificates and API keys.",
|
||||
};
|
||||
|
||||
export default function Signup() {
|
||||
return <SignupClient />;
|
||||
}
|
||||
208
src/app/(public)/tools/chat-id/ChatIdClient.tsx
Normal file
208
src/app/(public)/tools/chat-id/ChatIdClient.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
|
||||
interface Chat {
|
||||
id: number;
|
||||
name: string;
|
||||
username: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export default function ChatIdClient() {
|
||||
const [botToken, setBotToken] = useState("");
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [copiedId, setCopiedId] = useState<number | null>(null);
|
||||
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
||||
const t = useTranslations("Public");
|
||||
|
||||
// Load darkMode preference if needed, but for now relying on system/tailwind
|
||||
|
||||
const findChats = async () => {
|
||||
if (!botToken) {
|
||||
setError("Please enter a Bot Token");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setChats([]);
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.telegram.org/bot${botToken}/getUpdates`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.ok) {
|
||||
setError(data.description || "Invalid token or API error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.result.length === 0) {
|
||||
setError("No recent messages found. Please send a message to your bot first!");
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueChats: Record<number, Chat> = {};
|
||||
data.result.forEach((update: any) => {
|
||||
const message = update.message || update.edited_message || update.channel_post || update.edited_channel_post || update.callback_query?.message;
|
||||
if (message && message.chat) {
|
||||
uniqueChats[message.chat.id] = {
|
||||
id: message.chat.id,
|
||||
name: message.chat.title || message.chat.first_name || "Group/Channel",
|
||||
username: message.chat.username ? `@${message.chat.username}` : "N/A",
|
||||
type: message.chat.type,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const chatList = Object.values(uniqueChats);
|
||||
setChats(chatList);
|
||||
|
||||
if (chatList.length === 0) {
|
||||
setError("Could not find any chat information in recent updates.");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Network error. Please check your connection.");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (id: number) => {
|
||||
navigator.clipboard.writeText(id.toString());
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-32 pb-20 px-4 relative z-10 flex-grow flex flex-col">
|
||||
<div className="max-w-2xl mx-auto space-y-12 relative z-10 w-full">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 text-blue-600 dark:text-blue-400 text-[10px] font-bold uppercase tracking-widest mb-6">
|
||||
💬 Telegram Utility
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold text-gray-900 dark:text-white mb-4">
|
||||
{t("chat_id_title")}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 max-w-md mx-auto">
|
||||
{t("chat_id_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white px-8 py-10 shadow-2xl rounded-[2.5rem] dark:bg-white/[0.03] border border-gray-100 dark:border-gray-800 backdrop-blur-sm">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-2">{t("bot_token_label")}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={botToken}
|
||||
onChange={(e) => setBotToken(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && findChats()}
|
||||
placeholder="123456789:ABCDE..."
|
||||
className="w-full bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-3 font-mono text-sm text-blue-600 dark:text-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all pr-32"
|
||||
/>
|
||||
<button
|
||||
onClick={findChats}
|
||||
disabled={loading || !turnstileToken}
|
||||
className="absolute right-2 top-2 bottom-2 px-4 bg-brand-500 text-white text-xs font-bold rounded-lg hover:bg-brand-600 transition-all flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="animate-spin h-4 w-4 text-white" 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>
|
||||
) : (
|
||||
t("get_updates")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center py-2">
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ""}
|
||||
onSuccess={(token) => setTurnstileToken(token)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-800 text-red-600 dark:text-red-400 text-sm flex gap-3 items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chats.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-gray-800 dark:text-white">{t("detected_chats")}</h3>
|
||||
<div className="overflow-hidden rounded-xl border border-gray-100 dark:border-gray-700">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 dark:text-gray-400">{t("chat_name")}</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 dark:text-gray-400">{t("chat_id")}</th>
|
||||
<th className="px-4 py-3 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{chats.map((chat) => (
|
||||
<tr key={chat.id} className="hover:bg-gray-50 dark:hover:bg-gray-900/30 transition-colors">
|
||||
<td className="px-4 py-4">
|
||||
<div className="font-medium text-gray-800 dark:text-white">{chat.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{chat.username}</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 font-mono text-blue-600 dark:text-blue-400">{chat.id}</td>
|
||||
<td className="px-4 py-4 text-right">
|
||||
<button
|
||||
onClick={() => copyToClipboard(chat.id)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-400 hover:text-blue-500 transition-all"
|
||||
title="Copy ID"
|
||||
>
|
||||
{copiedId === chat.id ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-6 border-t border-gray-100 dark:border-gray-700">
|
||||
<h3 className="text-xs font-bold text-gray-800 dark:text-white mb-2 uppercase tracking-tight">{t("instructions")}</h3>
|
||||
<ol className="text-xs text-gray-500 dark:text-gray-400 space-y-2 list-decimal ml-4">
|
||||
<li>{t("instruction_1")}</li>
|
||||
<li>{t("instruction_2")}</li>
|
||||
<li>{t("instruction_3")}</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 text-center">
|
||||
<Link href="/" className="text-xs font-bold text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||
← {t("return_home")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/(public)/tools/chat-id/page.tsx
Normal file
11
src/app/(public)/tools/chat-id/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import ChatIdClient from "./ChatIdClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Chat ID Finder",
|
||||
description: "A simple tool to find your Telegram Chat ID.",
|
||||
};
|
||||
|
||||
export default function ChatIdFinder() {
|
||||
return <ChatIdClient />;
|
||||
}
|
||||
126
src/app/(public)/tools/key-generator/KeyGeneratorClient.tsx
Normal file
126
src/app/(public)/tools/key-generator/KeyGeneratorClient.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
|
||||
export default function KeyGeneratorClient() {
|
||||
const [generatedKey, setGeneratedKey] = useState("");
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
||||
const t = useTranslations("Public");
|
||||
|
||||
const generate = () => {
|
||||
const array = new Uint8Array(32);
|
||||
window.crypto.getRandomValues(array);
|
||||
const binary = Array.from(array, (byte) => String.fromCharCode(byte)).join('');
|
||||
setGeneratedKey('base64:' + btoa(binary));
|
||||
};
|
||||
|
||||
const copy = () => {
|
||||
if (!generatedKey) return;
|
||||
navigator.clipboard.writeText(generatedKey);
|
||||
setCopying(true);
|
||||
setTimeout(() => setCopying(false), 2000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
generate();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="pt-32 pb-20 px-4 relative z-10 flex-grow flex flex-col">
|
||||
<div className="max-w-2xl mx-auto space-y-12 relative z-10">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 text-blue-600 dark:text-blue-400 text-[10px] font-bold uppercase tracking-widest mb-6">
|
||||
🔐 Security Utility
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold text-gray-900 dark:text-white mb-4">
|
||||
{t("key_gen_title")}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 max-w-md mx-auto">
|
||||
{t("key_gen_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white px-8 py-10 shadow-2xl rounded-[2.5rem] dark:bg-white/[0.03] border border-gray-100 dark:border-gray-800 backdrop-blur-sm">
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-4 text-center">{t("generated_key_label")}</label>
|
||||
<div className="relative group">
|
||||
<div className="flex items-center gap-2 p-1 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-2xl">
|
||||
<div className="flex-1 p-5 text-sm font-mono text-gray-900 dark:text-white break-all text-center min-h-[60px] flex items-center justify-center">
|
||||
{generatedKey || t("loading")}
|
||||
</div>
|
||||
{generatedKey && (
|
||||
<button
|
||||
onClick={copy}
|
||||
className="p-4 mr-1 bg-brand-500 text-white rounded-xl shadow-lg shadow-brand-500/20 hover:scale-105 transition-all active:scale-95 flex-shrink-0"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{!copying ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="text-white"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{copying && (
|
||||
<div className="absolute -top-10 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-[10px] font-bold px-4 py-1.5 rounded-full shadow-lg animate-in fade-in slide-in-from-bottom-2 z-20">
|
||||
COPIED!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center py-2">
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ""}
|
||||
onSuccess={(token) => setTurnstileToken(token)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 pt-4">
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={!turnstileToken}
|
||||
className="flex-1 rounded-2xl bg-gray-900 dark:bg-white text-white dark:text-gray-900 px-6 py-4 text-sm font-bold shadow-lg transition-all hover:-translate-y-0.5 active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
{t("generate_new")}
|
||||
</button>
|
||||
<button
|
||||
onClick={copy}
|
||||
disabled={!turnstileToken}
|
||||
className="flex-1 rounded-2xl bg-brand-500 text-white px-6 py-4 text-sm font-bold shadow-lg shadow-brand-500/20 transition-all hover:-translate-y-0.5 active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
{t("copy_env")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-800/50 rounded-3xl border border-gray-100 dark:border-gray-700">
|
||||
<h4 className="text-xs font-bold text-gray-800 dark:text-white uppercase tracking-widest mb-3 flex items-center gap-2">
|
||||
{t("quick_guide")}
|
||||
</h4>
|
||||
<ul className="text-[11px] text-gray-500 dark:text-gray-400 space-y-2 font-medium">
|
||||
<li className="flex gap-2">
|
||||
<span className="text-brand-500 font-bold">1.</span>
|
||||
<div>{t("guide_1")}</div>
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="text-brand-500 font-bold">2.</span>
|
||||
<div>{t("guide_2")}</div>
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="text-brand-500 font-bold">3.</span>
|
||||
<div>{t("guide_3")}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/(public)/tools/key-generator/page.tsx
Normal file
11
src/app/(public)/tools/key-generator/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import KeyGeneratorClient from "./KeyGeneratorClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "APP_KEY Generator",
|
||||
description: "Securely generate production-ready Larvel APP_KEY in your browser.",
|
||||
};
|
||||
|
||||
export default function AppKeyGenerator() {
|
||||
return <KeyGeneratorClient />;
|
||||
}
|
||||
100
src/app/(public)/verify-email/page.tsx
Normal file
100
src/app/(public)/verify-email/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import axios from "@/lib/axios";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import { Mail, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const { addToast } = useToast();
|
||||
const t = useTranslations("VerifyEmail");
|
||||
const [isResending, setIsResending] = React.useState(false);
|
||||
const [countdown, setCountdown] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
const handleResend = async () => {
|
||||
if (countdown > 0) return;
|
||||
|
||||
setIsResending(true);
|
||||
try {
|
||||
await axios.post("/api/email/verification-notification");
|
||||
addToast(t("toast_sent"), "success");
|
||||
setCountdown(60); // 1 minute throttle
|
||||
} catch (error: any) {
|
||||
addToast(error.response?.data?.message || t("toast_fail"), "error");
|
||||
} finally {
|
||||
setIsResending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 dark:bg-gray-900 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md space-y-8 text-center">
|
||||
<div>
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
|
||||
<Mail className="h-10 w-10 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{t.rich("desc", {
|
||||
email: (chunks) => <span className="font-semibold text-gray-900 dark:text-white">{user?.email}</span>
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
onClick={handleResend}
|
||||
disabled={isResending || countdown > 0}
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{isResending ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
{countdown > 0 ? t("resend_in", { seconds: countdown }) : t("resend_button")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 pt-4">
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
className="flex items-center gap-2 text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("sign_out")}
|
||||
</button>
|
||||
|
||||
<span className="text-gray-300 dark:text-gray-700">|</span>
|
||||
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
{t("back_dashboard")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-yellow-50 p-4 border border-yellow-100 dark:bg-yellow-900/20 dark:border-yellow-900/30">
|
||||
<p className="text-xs text-yellow-800 dark:text-yellow-200 text-left">
|
||||
<strong>{t("note_title")}</strong> {t("note_desc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/app/(public)/verify-success/page.tsx
Normal file
50
src/app/(public)/verify-success/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { CheckCircle, AlertCircle } from "lucide-react";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function VerifySuccessPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations("VerifyEmail");
|
||||
const verified = searchParams.get("verified");
|
||||
const alreadyVerified = searchParams.get("already_verified");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 dark:bg-gray-900 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md space-y-8 text-center">
|
||||
<div>
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
{verified || alreadyVerified ? (
|
||||
<CheckCircle className="h-10 w-10 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="h-10 w-10 text-yellow-600 dark:text-yellow-400" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
{alreadyVerified ? t("already_verified_title") : (verified ? t("success_title") : t("status_title"))}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{alreadyVerified
|
||||
? t("already_verified_desc")
|
||||
: (verified
|
||||
? t("success_desc")
|
||||
: t("status_fail_desc"))
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Link href="/dashboard" className="block">
|
||||
<Button className="w-full">
|
||||
{t("go_dashboard")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
391
src/app/dashboard/DashboardClient.tsx
Normal file
391
src/app/dashboard/DashboardClient.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import useSWR from "swr";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import axios from "@/lib/axios";
|
||||
import {
|
||||
BarChart3,
|
||||
Users,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
Activity,
|
||||
Clock,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Server,
|
||||
CheckCircle,
|
||||
MessageSquare,
|
||||
Download,
|
||||
LogIn,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
FilePlus,
|
||||
Image as ImageIcon
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import echo from "@/lib/echo";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import Tooltip from "@/components/ui/Tooltip";
|
||||
import { getUserAvatar } from "@/lib/utils";
|
||||
|
||||
const Chart = dynamic(() => import("react-apexcharts"), { ssr: false });
|
||||
|
||||
const getActivityIcon = (action: string) => {
|
||||
switch (action) {
|
||||
case 'login': return <LogIn className="w-4 h-4 text-blue-500" />;
|
||||
case 'register': return <UserPlus className="w-4 h-4 text-green-500" />;
|
||||
case 'issue_cert': return <FilePlus className="w-4 h-4 text-brand-500" />;
|
||||
case 'delete_cert': return <Trash2 className="w-4 h-4 text-red-500" />;
|
||||
case 'create_ticket': return <MessageSquare className="w-4 h-4 text-purple-500" />;
|
||||
case 'reply_ticket': return <MessageSquare className="w-4 h-4 text-indigo-500" />;
|
||||
case 'close_ticket': return <CheckCircle className="w-4 h-4 text-gray-500" />;
|
||||
default: return <Activity className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
|
||||
|
||||
export default function DashboardClient() {
|
||||
const t = useTranslations("Dashboard");
|
||||
const { data, error, isLoading, mutate } = useSWR("/api/dashboard", fetcher, {
|
||||
refreshInterval: 0, // Disable auto polling, rely on WS or manual refresh
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const { data: userData } = useSWR("/api/user", fetcher);
|
||||
const user = userData;
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.default_landing_page && user.default_landing_page !== '/dashboard') {
|
||||
router.push(user.default_landing_page);
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
const [wsStatus, setWsStatus] = useState<"connected" | "disconnected" | "connecting">("connecting");
|
||||
const [apiLatency, setApiLatency] = useState<number | null>(null);
|
||||
const [wsLatency, setWsLatency] = useState<string>("Unknown");
|
||||
|
||||
const stats = data?.data?.stats;
|
||||
const activity = data?.data?.recent_activity;
|
||||
const chartData = data?.data?.chart_data;
|
||||
|
||||
// Realtime & Latency Logic
|
||||
useEffect(() => {
|
||||
if (!echo) return;
|
||||
|
||||
// WebSocket Status
|
||||
if (echo.connector.pusher.connection.state === 'connected') {
|
||||
setWsStatus('connected');
|
||||
}
|
||||
|
||||
echo.connector.pusher.connection.bind('connected', () => {
|
||||
setWsStatus('connected');
|
||||
setWsLatency("Active");
|
||||
});
|
||||
|
||||
echo.connector.pusher.connection.bind('disconnected', () => {
|
||||
setWsStatus('disconnected');
|
||||
});
|
||||
|
||||
echo.connector.pusher.connection.bind('connecting', () => {
|
||||
setWsStatus('connecting');
|
||||
});
|
||||
|
||||
// Listen for global dashboard updates if implemented
|
||||
// echo.channel('dashboard').listen('.DashboardUpdated', () => { mutate(); });
|
||||
|
||||
return () => {
|
||||
// cleanup if needed
|
||||
};
|
||||
}, []);
|
||||
|
||||
// API Latency Check
|
||||
const checkApiLatency = async () => {
|
||||
const start = performance.now();
|
||||
try {
|
||||
await axios.get('/api/dashboard/ping');
|
||||
const end = performance.now();
|
||||
setApiLatency(Math.round(end - start));
|
||||
} catch (e) {
|
||||
setApiLatency(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkApiLatency();
|
||||
const interval = setInterval(checkApiLatency, 1000); // Check every 1s (Realtime-like)
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
|
||||
if (isLoading) return <PageLoader text={t('loading_dashboard')} />;
|
||||
if (error) return <div className="p-6 text-red-500">{t('error_loading')}</div>;
|
||||
|
||||
// Chart Configuration
|
||||
const chartOptions: any = {
|
||||
colors: ["#465fff"],
|
||||
chart: {
|
||||
fontFamily: "inherit",
|
||||
type: "area",
|
||||
height: 310,
|
||||
toolbar: { show: false },
|
||||
},
|
||||
stroke: { curve: "smooth", width: 2 },
|
||||
fill: {
|
||||
type: "gradient",
|
||||
gradient: {
|
||||
shadeIntensity: 1,
|
||||
opacityFrom: 0.45,
|
||||
opacityTo: 0.05,
|
||||
stops: [0, 80, 100],
|
||||
},
|
||||
},
|
||||
dataLabels: { enabled: false },
|
||||
grid: {
|
||||
borderColor: "#e5e7eb",
|
||||
strokeDashArray: 3,
|
||||
xaxis: { lines: { show: false } },
|
||||
},
|
||||
xaxis: {
|
||||
categories: chartData?.map((d: any) => d.day) || [],
|
||||
axisBorder: { show: false },
|
||||
axisTicks: { show: false },
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
formatter: (val: number) => Math.floor(val)
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
x: { format: "dd/MM/yy HH:mm" },
|
||||
},
|
||||
};
|
||||
|
||||
const chartSeries = [
|
||||
{
|
||||
name: t("issued_certs"),
|
||||
data: chartData?.map((d: any) => d.count) || [],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 dark:text-white">{t("overview")}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t("metrics_health")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* System Health Indicators */}
|
||||
<Tooltip content={t("ws_tooltip")} position="bottom-start">
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border ${
|
||||
wsStatus === 'connected'
|
||||
? 'bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800'
|
||||
: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800'
|
||||
}`}>
|
||||
{wsStatus === 'connected' ? <Wifi className="w-3.5 h-3.5" /> : <WifiOff className="w-3.5 h-3.5" />}
|
||||
<span>{t("ws_status")}: {wsStatus === 'connected' ? t("live") : t("offline")}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t("latency_tooltip")} position="bottom-end">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800">
|
||||
<Activity className="w-3.5 h-3.5" />
|
||||
<span>{t("api_status")}: {apiLatency !== null ? `${apiLatency}ms` : 'N/A'}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Certificate Stats */}
|
||||
<StatsCard
|
||||
title={t("total_certificates")}
|
||||
value={stats?.total_certificates || 0}
|
||||
icon={<FileText className="w-6 h-6 text-blue-500" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title={t("active_certificates")}
|
||||
value={stats?.active_certificates || 0}
|
||||
icon={<CheckCircle className="w-6 h-6 text-green-500" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title={t("expired")}
|
||||
value={stats?.expired_certificates || 0}
|
||||
icon={<AlertCircle className="w-6 h-6 text-orange-500" />}
|
||||
footer={stats?.expired_certificates?.value > 0 ? t("action_needed") : t("all_good")}
|
||||
alert={stats?.expired_certificates?.value > 0}
|
||||
/>
|
||||
<StatsCard
|
||||
title={t("active_tickets")}
|
||||
value={stats?.active_tickets || 0}
|
||||
icon={<Users className="w-6 h-6 text-purple-500" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Admin Extra Stats (Conditional Render in real app, filtering here for demo) */}
|
||||
{stats?.total_users !== undefined && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title={t("total_users")}
|
||||
value={stats.total_users}
|
||||
icon={<Users className="w-6 h-6 text-indigo-500" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title={t("pending_inquiries")}
|
||||
value={stats.pending_inquiries}
|
||||
icon={<MessageSquare className="w-6 h-6 text-pink-500" />}
|
||||
alert={stats.pending_inquiries?.value > 0}
|
||||
footer={stats.pending_inquiries?.value > 0 ? t("response_required") : t("no_new_messages")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CA Download Stats (Admin Only) */}
|
||||
{stats?.ca_downloads_root !== undefined && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">{t("ca_downloads")}</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatsCard
|
||||
title={t("root_ca")}
|
||||
value={stats.ca_downloads_root}
|
||||
icon={<Download className="w-6 h-6 text-brand-500" />}
|
||||
footer={t("global_trust_root")}
|
||||
/>
|
||||
<StatsCard
|
||||
title={t("intermediate_2048")}
|
||||
value={stats.ca_downloads_intermediate_2048}
|
||||
icon={<Download className="w-6 h-6 text-blue-500" />}
|
||||
footer={t("standard_issuance")}
|
||||
/>
|
||||
<StatsCard
|
||||
title={t("intermediate_4096")}
|
||||
value={stats.ca_downloads_intermediate_4096}
|
||||
icon={<Download className="w-6 h-6 text-indigo-500" />}
|
||||
footer={t("high_security")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log / Activity Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:bg-gray-900 dark:border-gray-800 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">{t("recent_activity")}</h3>
|
||||
<button className="text-sm text-brand-500 hover:underline">{t("view_all")}</button>
|
||||
</div>
|
||||
{activity && activity.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{activity.map((item: any, i: number) => (
|
||||
<div key={item.id || i} className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-50 dark:bg-gray-800 flex-shrink-0 flex items-center justify-center relative">
|
||||
<Image
|
||||
src={getUserAvatar({ avatar: item.user_avatar, name: item.user_name })}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="w-full h-full object-cover rounded-full"
|
||||
unoptimized={true}
|
||||
/>
|
||||
<div className="absolute -bottom-1 -right-1 bg-white dark:bg-gray-900 rounded-full p-1 shadow-theme-sm border border-gray-100 dark:border-gray-800">
|
||||
{getActivityIcon(item.action)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
<span className="text-brand-500 font-bold">{item.user_name}</span> {item.description || item.action}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{new Date(item.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-gray-400">
|
||||
<Clock className="w-8 h-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">{t("no_activity")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:bg-gray-900 dark:border-gray-800 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">{t("certificate_trends")}</h3>
|
||||
<div className="flex items-center gap-1 text-xs text-green-500 font-medium">
|
||||
<Activity className="w-3.5 h-3.5" />
|
||||
<span>{t("last_7_days")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-[250px] w-full">
|
||||
<Chart
|
||||
options={chartOptions}
|
||||
series={chartSeries}
|
||||
type="area"
|
||||
height={250}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Subcomponents
|
||||
function StatsCard({ title, value, icon, trend, trendLabel, footer, alert }: any) {
|
||||
const t = useTranslations("Dashboard");
|
||||
const isPositive = typeof trend === 'number' ? trend >= 0 : false;
|
||||
const trendValue = typeof trend === 'number' ? `${isPositive ? '+' : ''}${trend}%` : trend;
|
||||
|
||||
// Determine the value to display
|
||||
const displayValue = typeof value === 'object' ? value.value : value;
|
||||
|
||||
// Determine trend data from value object or props
|
||||
const effectiveTrend = value?.trend !== undefined ? value.trend : trend;
|
||||
const effectiveTrendLabel = value?.trend_label !== undefined ? value.trend_label : trendLabel;
|
||||
|
||||
return (
|
||||
<div className={`p-5 bg-white border rounded-2xl dark:bg-gray-900 shadow-sm transition-all hover:shadow-md ${alert ? 'border-red-200 dark:border-red-900/30' : 'border-gray-200 dark:border-gray-800'}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{title}</p>
|
||||
<h4 className="mt-2 text-2xl font-bold text-gray-800 dark:text-white">{displayValue}</h4>
|
||||
</div>
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-xl ${alert ? 'bg-red-50 text-red-500' : 'bg-gray-50 dark:bg-gray-800'}`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Bottom Section - Standardized Layout */}
|
||||
{(effectiveTrend !== undefined || footer) && (
|
||||
<div className={`mt-4 flex items-center gap-1 text-xs font-medium ${
|
||||
footer ? (alert ? 'text-red-500' : 'text-gray-500') :
|
||||
(isPositive && effectiveTrend >= 0) ? 'text-green-500' : 'text-red-500'
|
||||
}`}>
|
||||
{footer ? (
|
||||
// Custom Footer (replaces trend)
|
||||
<span>{footer}</span>
|
||||
) : (
|
||||
// Standard Trend
|
||||
<>
|
||||
<span>
|
||||
{typeof effectiveTrend === 'number' && effectiveTrend > 0 ? '+' : ''}
|
||||
{effectiveTrend}%
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
{effectiveTrendLabel || t("vs_last_month")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback for cards with NO footer/trend to keep height consistent (optional, removed for now as not requested) */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
300
src/app/dashboard/admin/inquiries/InquiryClient.tsx
Normal file
300
src/app/dashboard/admin/inquiries/InquiryClient.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import axios from "@/lib/axios";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import ConfirmationModal from "@/components/common/ConfirmationModal";
|
||||
import { Eye, Trash, Mail, Calendar, Search, MessageSquare, Send, X } from "lucide-react";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
|
||||
|
||||
export default function InquiryClient() {
|
||||
const t = useTranslations("Inquiries");
|
||||
const { addToast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isReplying, setIsReplying] = useState(false);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [selectedInquiry, setSelectedInquiry] = useState<any>(null);
|
||||
const [replyMessage, setReplyMessage] = useState("");
|
||||
|
||||
const { data, error, mutate, isLoading } = useSWR("/api/admin/inquiries", fetcher);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await axios.delete(`/api/admin/inquiries/${id}`);
|
||||
mutate();
|
||||
addToast(t("toast_deleted"), "success");
|
||||
setConfirmDeleteId(null);
|
||||
} catch (err: any) {
|
||||
addToast(err.response?.data?.message || t("toast_delete_failed"), "error");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendReply = async () => {
|
||||
if (!replyMessage.trim()) {
|
||||
addToast(t("toast_reply_req"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsReplying(true);
|
||||
try {
|
||||
await axios.post(`/api/admin/inquiries/${selectedInquiry.id}/reply`, {
|
||||
message: replyMessage
|
||||
});
|
||||
addToast(t("toast_reply_sent"), "success");
|
||||
mutate();
|
||||
setSelectedInquiry(null);
|
||||
setReplyMessage("");
|
||||
} catch (err: any) {
|
||||
addToast(err.response?.data?.message || t("toast_reply_failed"), "error");
|
||||
} finally {
|
||||
setIsReplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inquiries = data?.data || [];
|
||||
|
||||
const filteredInquiries = useMemo(() => {
|
||||
return inquiries.filter((iq: any) => {
|
||||
const matchesSearch =
|
||||
iq.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
iq.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
iq.subject.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === "all" || iq.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [inquiries, searchTerm, statusFilter]);
|
||||
|
||||
if (error) return <div className="p-10 text-center text-error-500">{t("load_failed")}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle={t("management_title")} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<ComponentCard
|
||||
title={t("card_title")}
|
||||
desc={t("card_desc")}
|
||||
headerAction={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative hidden sm:block">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<Search className="w-4 h-4" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-800 rounded-lg bg-transparent focus:ring-2 focus:ring-brand-500/10 outline-none w-64 dark:text-white/90 dark:placeholder:text-white/30"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-gray-200 dark:border-gray-800 rounded-lg bg-transparent outline-none dark:bg-gray-900 dark:text-white/90"
|
||||
>
|
||||
<option value="all">{t("all_status")}</option>
|
||||
<option value="pending">{t("status_pending")}</option>
|
||||
<option value="replied">{t("status_replied")}</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="relative overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-gray-800">
|
||||
<th className="px-5 py-4 text-xs font-bold text-gray-500 uppercase tracking-widest">{t("sender_th")}</th>
|
||||
<th className="px-5 py-4 text-xs font-bold text-gray-500 uppercase tracking-widest">{t("subject_category_th")}</th>
|
||||
<th className="px-5 py-4 text-xs font-bold text-gray-500 uppercase tracking-widest">{t("status_th")}</th>
|
||||
<th className="px-5 py-4 text-xs font-bold text-gray-500 uppercase tracking-widest">{t("date_th")}</th>
|
||||
<th className="px-5 py-4 text-xs font-bold text-gray-500 uppercase tracking-widest text-right">{t("actions_th")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50 dark:divide-gray-800/50">
|
||||
{filteredInquiries.length > 0 ? (
|
||||
filteredInquiries.map((iq: any) => (
|
||||
<tr key={iq.id} className="hover:bg-gray-50/50 dark:hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-gray-900 dark:text-white">{iq.name}</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{iq.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate max-w-[200px]">{iq.subject}</span>
|
||||
<span className="text-[10px] text-gray-400 uppercase tracking-wider font-bold">{t("category_label", { category: iq.category })}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest ${iq.status === 'replied' ? 'bg-green-50 text-green-600 dark:bg-green-500/10 dark:text-green-400' : 'bg-orange-50 text-orange-600 dark:bg-orange-500/10 dark:text-orange-400'}`}>
|
||||
{t(`status_${iq.status}`)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 flex items-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
{new Date(iq.created_at).toLocaleDateString("en-US", {
|
||||
month: "short", day: "numeric"
|
||||
})}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedInquiry(iq)}
|
||||
className="p-2 text-gray-400 hover:text-brand-500 transition-colors"
|
||||
title={t("view_reply")}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteId(iq.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title={t("delete_inquiry")}
|
||||
>
|
||||
<Trash className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : !isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-10 text-center text-gray-500 dark:text-gray-400 italic">
|
||||
{t("no_inquiries")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{(isLoading || isDeleting) && (
|
||||
<div className="absolute inset-0 bg-white/50 dark:bg-gray-900/50 flex items-center justify-center z-10 backdrop-blur-sm rounded-2xl">
|
||||
<PageLoader text={t("processing")} className="h-full" />
|
||||
</div>
|
||||
)}
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={confirmDeleteId !== null}
|
||||
onClose={() => setConfirmDeleteId(null)}
|
||||
onConfirm={() => confirmDeleteId && handleDelete(confirmDeleteId)}
|
||||
title={t("delete_title")}
|
||||
message={t("delete_message")}
|
||||
isLoading={isDeleting}
|
||||
confirmLabel={t("delete_confirm")}
|
||||
requiredInput="DELETE"
|
||||
/>
|
||||
|
||||
{/* Inquiry View / Reply Modal */}
|
||||
{selectedInquiry && (
|
||||
<div className="fixed inset-0 z-[100000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={() => !isReplying && setSelectedInquiry(null)}></div>
|
||||
<div className="relative bg-white dark:bg-gray-900 w-full max-w-2xl rounded-3xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 dark:bg-brand-500/10 flex items-center justify-center text-brand-500">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white">{t("details_title")}</h3>
|
||||
<p className="text-xs text-gray-500">{selectedInquiry.subject}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => !isReplying && setSelectedInquiry(null)}
|
||||
className="p-2 text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
disabled={isReplying}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-widest font-bold">{t("sender_th")}</p>
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white">{selectedInquiry.name}</p>
|
||||
<p className="text-xs text-gray-500">{selectedInquiry.email}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-widest font-bold">{t("details_label")}</p>
|
||||
<p className="text-sm text-gray-900 dark:text-white font-bold">{selectedInquiry.category}</p>
|
||||
<p className="text-xs text-gray-500">{t("received_at", { date: new Date(selectedInquiry.created_at).toLocaleString() })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-widest font-bold">{t("message_label")}</p>
|
||||
<div className="p-4 bg-gray-50 dark:bg-white/[0.03] rounded-2xl border border-gray-100 dark:border-gray-800 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap italic">
|
||||
"{selectedInquiry.message}"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedInquiry.status === 'replied' && (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-500/10 rounded-2xl border border-green-100 dark:border-green-500/20">
|
||||
<p className="text-xs text-green-600 dark:text-green-400 font-bold mb-1 flex items-center gap-1.5">
|
||||
<Send className="w-3.5 h-3.5" /> {t("replied_on", { date: new Date(selectedInquiry.replied_at).toLocaleString() })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 pt-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] text-gray-400 uppercase tracking-widest font-bold">{t("quick_reply")}</p>
|
||||
<a
|
||||
href={`mailto:${selectedInquiry.email}?subject=Re: ${selectedInquiry.subject}`}
|
||||
className="text-[10px] text-brand-500 hover:underline font-bold"
|
||||
>
|
||||
{t("external_client")}
|
||||
</a>
|
||||
</div>
|
||||
<textarea
|
||||
value={replyMessage}
|
||||
onChange={(e) => setReplyMessage(e.target.value)}
|
||||
placeholder={t("reply_placeholder")}
|
||||
className="w-full rounded-2xl border-gray-200 bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white p-4 text-sm focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none min-h-[120px] transition-all"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-white/[0.02] border-t border-gray-100 dark:border-gray-800 flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedInquiry(null)}
|
||||
disabled={isReplying}
|
||||
>
|
||||
{t("cancel_button")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSendReply}
|
||||
loading={isReplying}
|
||||
startIcon={<Send className="w-4 h-4" />}
|
||||
>
|
||||
{t("send_reply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/dashboard/admin/inquiries/page.tsx
Normal file
11
src/app/dashboard/admin/inquiries/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import InquiryClient from "./InquiryClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contact Inquiries",
|
||||
description: "View and respond to inquiries from the public contact form.",
|
||||
};
|
||||
|
||||
export default function InquiriesPage() {
|
||||
return <InquiryClient />;
|
||||
}
|
||||
333
src/app/dashboard/admin/legal/AdminLegalEditorClient.tsx
Normal file
333
src/app/dashboard/admin/legal/AdminLegalEditorClient.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import axios from "@/lib/axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Save, ArrowLeft, Eye, Edit3 } from "lucide-react";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import Link from "next/link";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface EditorProps {
|
||||
mode: "create" | "edit";
|
||||
initialData?: any;
|
||||
}
|
||||
|
||||
export default function AdminLegalEditorClient({ mode, initialData }: EditorProps) {
|
||||
const t = useTranslations("LegalAdmin");
|
||||
const router = useRouter();
|
||||
const { addToast } = useToast();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'write' | 'preview'>('write');
|
||||
|
||||
// Load history if edit mode
|
||||
const [versionHistory, setVersionHistory] = useState<any[]>([]);
|
||||
|
||||
// Computed potential parents
|
||||
const [majors, setMajors] = useState<number[]>([]);
|
||||
const [minors, setMinors] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'edit' && initialData?.id) {
|
||||
axios.get(`/api/admin/legal-pages/${initialData.id}/history`)
|
||||
.then(res => {
|
||||
const hist = res.data.data;
|
||||
setVersionHistory(hist);
|
||||
|
||||
// Extract unique majors
|
||||
const m = Array.from(new Set(hist.map((r: any) => r.major))) as number[];
|
||||
setMajors(m.sort((a,b) => b-a));
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
}, [mode, initialData]);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: initialData?.title || "",
|
||||
content: initialData?.latest_revision?.content || "",
|
||||
status: initialData?.latest_revision?.status || 'draft',
|
||||
change_log: "",
|
||||
|
||||
// Versioning State
|
||||
version_type: 'patch', // major, minor, patch
|
||||
parent_major: initialData?.latest_revision?.major || 1,
|
||||
parent_minor: initialData?.latest_revision?.minor || 0,
|
||||
});
|
||||
|
||||
// Update minors dropdown when parent_major changes
|
||||
useEffect(() => {
|
||||
if (mode === 'edit') {
|
||||
const relevantRevisions = versionHistory.filter((r: any) => r.major === Number(formData.parent_major));
|
||||
const m = Array.from(new Set(relevantRevisions.map((r: any) => r.minor))) as number[];
|
||||
setMinors(m.sort((a,b) => b-a));
|
||||
|
||||
// default to 0 or first avail
|
||||
if (!m.includes(Number(formData.parent_minor))) {
|
||||
setFormData(prev => ({...prev, parent_minor: m[0] || 0}));
|
||||
}
|
||||
}
|
||||
}, [formData.parent_major, versionHistory, mode]);
|
||||
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent, submitStatus: 'draft' | 'published') => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (mode === "create") {
|
||||
await axios.post("/api/admin/legal-pages", {
|
||||
title: formData.title,
|
||||
content: formData.content,
|
||||
status: submitStatus
|
||||
});
|
||||
addToast(t("toast_save_success"), "success");
|
||||
} else {
|
||||
await axios.put(`/api/admin/legal-pages/${initialData.id}`, {
|
||||
title: formData.title,
|
||||
content: formData.content,
|
||||
status: submitStatus,
|
||||
change_log: formData.change_log,
|
||||
version_type: formData.version_type,
|
||||
parent_major: formData.parent_major,
|
||||
parent_minor: formData.parent_minor
|
||||
});
|
||||
addToast(t("toast_save_success"), "success");
|
||||
}
|
||||
router.push("/dashboard/admin/legal");
|
||||
} catch (error: any) {
|
||||
addToast(error.response?.data?.message || t("toast_save_failed"), "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Compute Next Version String for visualization
|
||||
const getNextVersion = () => {
|
||||
if (mode === 'create') return "1.0.0";
|
||||
|
||||
let nextMajor = 0, nextMinor = 0, nextPatch = 0;
|
||||
|
||||
if (formData.version_type === 'major') {
|
||||
const maxMaj = majors.length > 0 ? Math.max(...majors) : 0;
|
||||
nextMajor = maxMaj + 1;
|
||||
} else if (formData.version_type === 'minor') {
|
||||
nextMajor = Number(formData.parent_major);
|
||||
const relevant = versionHistory.filter(r => r.major === nextMajor).map(r => r.minor);
|
||||
const maxMin = relevant.length ? Math.max(...relevant) : -1;
|
||||
nextMinor = maxMin + 1;
|
||||
} else {
|
||||
nextMajor = Number(formData.parent_major);
|
||||
nextMinor = Number(formData.parent_minor);
|
||||
const relevant = versionHistory.filter(r => r.major === nextMajor && r.minor === nextMinor).map(r => r.patch);
|
||||
const maxPatch = relevant.length ? Math.max(...relevant) : -1;
|
||||
nextPatch = maxPatch + 1;
|
||||
}
|
||||
return `${nextMajor}.${nextMinor}.${nextPatch}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageBreadcrumb pageTitle={mode === "create" ? t("editor_title_create") : t("editor_title_edit")} />
|
||||
|
||||
<Link
|
||||
href="/dashboard/admin/legal"
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-brand-500 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
{t("btn_cancel")}
|
||||
</Link>
|
||||
|
||||
<form>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<ComponentCard title={t("form_content_label")}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("form_title_label")}
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl focus:ring-2 focus:ring-brand-500 text-gray-900 dark:text-white"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
>
|
||||
<option value="" disabled>{t("form_title_placeholder")}</option>
|
||||
<option value="Privacy Policy">Privacy Policy</option>
|
||||
<option value="Terms and Conditions">Terms and Conditions</option>
|
||||
<option value="Cookie Policy">Cookie Policy</option>
|
||||
<option value="Disclaimer">Disclaimer</option>
|
||||
<option value="Acceptable Use Policy">Acceptable Use Policy</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Markdown Editor Tabs */}
|
||||
<div className="border border-gray-200 dark:border-gray-800 rounded-xl overflow-hidden">
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('write')}
|
||||
className={`px-4 py-3 text-sm font-bold flex items-center gap-2 ${activeTab === 'write' ? 'bg-white dark:bg-gray-800 text-brand-600 border-b-2 border-brand-500' : 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}`}
|
||||
>
|
||||
<Edit3 size={16} /> {t("btn_write", { fallback: "Write" })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('preview')}
|
||||
className={`px-4 py-3 text-sm font-bold flex items-center gap-2 ${activeTab === 'preview' ? 'bg-white dark:bg-gray-800 text-brand-600 border-b-2 border-brand-500' : 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}`}
|
||||
>
|
||||
<Eye size={16} /> {t("btn_preview", { fallback: "Preview" })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'write' ? (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
required
|
||||
rows={20}
|
||||
className="w-full px-4 py-4 bg-white dark:bg-gray-900 border-none focus:ring-0 font-mono text-sm text-gray-900 dark:text-gray-300 resize-y"
|
||||
placeholder="# Privacy Policy\n\nWrite your content in Markdown..."
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 text-xs text-gray-400 pointer-events-none">
|
||||
{t("markdown_supported", { fallback: "Markdown Supported" })}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 prose dark:prose-invert max-w-none min-h-[500px] bg-white dark:bg-gray-900
|
||||
prose-headings:font-bold prose-headings:!mt-1 prose-headings:!mb-0
|
||||
prose-p:!leading-tight prose-p:!my-0
|
||||
prose-ul:!my-0 prose-ol:!my-0
|
||||
prose-li:!my-0 prose-li:!leading-tight [&_li_p]:!my-0
|
||||
prose-a:text-brand-600 dark:prose-a:text-brand-400 prose-img:rounded-xl whitespace-pre-wrap">
|
||||
{formData.content ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{formData.content}</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-gray-500 italic">{t("no_content_preview", { fallback: "No content to preview" })}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<ComponentCard title={t("sidebar_details")}>
|
||||
<div className="space-y-6">
|
||||
{/* Status Indicator */}
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-xl border border-gray-200 dark:border-gray-800">
|
||||
<label className="block text-sm font-bold text-gray-900 dark:text-white mb-2">
|
||||
{t("current_status", { fallback: "Current Status" })}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<span className={`px-2 py-1 rounded-md text-xs font-bold uppercase ${formData.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-700'}`}>
|
||||
{formData.status}
|
||||
</span>
|
||||
{formData.status === 'published' && <span className="text-xs text-green-600 flex items-center">{t("status_live", { fallback: "Live on Site" })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'edit' && (
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300">
|
||||
{t("version_update_type", { fallback: "Version Update Type" })}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['major', 'minor', 'patch'].map(type => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({...prev, version_type: type}))}
|
||||
className={`px-2 py-2 text-xs font-bold uppercase rounded-lg border transition-all ${
|
||||
formData.version_type === type
|
||||
? 'bg-brand-50 dark:bg-brand-500/20 border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Parent Selectors for Minor/Patch */}
|
||||
{(formData.version_type === 'minor' || formData.version_type === 'patch') && (
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-1">{t("parent_major", { fallback: "Parent Major" })}</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
value={formData.parent_major}
|
||||
onChange={(e) => setFormData(prev => ({...prev, parent_major: Number(e.target.value)}))}
|
||||
>
|
||||
{majors.map(m => <option key={m} value={m}>v{m}.x.x</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.version_type === 'patch' && (
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-1">{t("parent_minor", { fallback: "Parent Minor" })}</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
value={formData.parent_minor}
|
||||
onChange={(e) => setFormData(prev => ({...prev, parent_minor: Number(e.target.value)}))}
|
||||
>
|
||||
{minors.map(m => <option key={m} value={m}>v{formData.parent_major}.{m}.x</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-500/10 rounded-lg text-blue-600 dark:text-blue-400 text-sm font-medium text-center border border-blue-100 dark:border-blue-500/20">
|
||||
{t("next_version", { fallback: "Next Version" })}: <span className="font-bold">{getNextVersion()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "edit" && (
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("form_summary_label")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl focus:ring-2 focus:ring-brand-500 text-gray-900 dark:text-white"
|
||||
placeholder={t("form_summary_placeholder")}
|
||||
value={formData.change_log}
|
||||
onChange={(e) => setFormData({ ...formData, change_log: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleSubmit(e, 'draft')}
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-200 dark:hover:bg-gray-700 transition-all disabled:opacity-50"
|
||||
>
|
||||
<Save size={18} />
|
||||
{t("btn_save_draft", { fallback: "Save Draft" })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleSubmit(e, 'published')}
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-brand-500 text-white rounded-xl font-bold hover:bg-brand-600 transition-all shadow-theme-md disabled:opacity-50"
|
||||
>
|
||||
<Eye size={18} />
|
||||
{mode === 'create' ? t("btn_create") : t("btn_save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
src/app/dashboard/admin/legal/AdminLegalListClient.tsx
Normal file
144
src/app/dashboard/admin/legal/AdminLegalListClient.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import axios from "@/lib/axios";
|
||||
import { Plus, Search, FileText, ChevronRight, Edit3, Trash2 } from "lucide-react";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import Link from "next/link";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
|
||||
|
||||
export default function AdminLegalListClient() {
|
||||
const t = useTranslations("LegalAdmin");
|
||||
const { addToast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const { data, mutate, isLoading } = useSWR("/api/admin/legal-pages", fetcher);
|
||||
|
||||
const pages = data?.data || [];
|
||||
const filteredPages = pages.filter((page: any) =>
|
||||
page.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
page.slug.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm(t("delete_confirm"))) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/admin/legal-pages/${id}`);
|
||||
addToast(t("toast_delete_success"), "success");
|
||||
mutate();
|
||||
} catch (error) {
|
||||
addToast(t("toast_delete_failed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageBreadcrumb pageTitle={t("management_title")} />
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<Search size={18} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("search_placeholder")}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-800 rounded-lg focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 text-gray-900 dark:text-white"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/admin/legal/create"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{t("create_new")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ComponentCard title={t("list_title")} className="p-0 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-50 dark:bg-white/5 border-b border-gray-100 dark:border-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">{t("th_title_slug")}</th>
|
||||
<th className="px-6 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">{t("th_version")}</th>
|
||||
<th className="px-6 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">{t("th_last_updated")}</th>
|
||||
<th className="px-6 py-4 text-xs font-bold text-gray-400 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={4} className="p-8 text-center text-gray-500 italic">
|
||||
<PageLoader text={t("loading_pages")} className="py-10" />
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredPages.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-8 text-center text-gray-500 italic">
|
||||
{t("no_pages")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredPages.map((page: any) => (
|
||||
<tr key={page.id} className="group hover:bg-gray-50 dark:hover:bg-white/5 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<div className="font-bold text-gray-900 dark:text-white">{page.title}</div>
|
||||
<code className="text-xs text-brand-500 bg-brand-50 dark:bg-brand-900/20 px-1.5 py-0.5 rounded border border-brand-100 dark:border-brand-800/30">
|
||||
/{page.slug}
|
||||
</code>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">
|
||||
v{page.latest_revision?.version || '1.0'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-xs text-gray-500">
|
||||
{new Date(page.updated_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/legal/view?slug=${page.slug}`}
|
||||
target="_blank"
|
||||
className="p-1.5 text-gray-400 hover:text-brand-500 hover:bg-brand-50 dark:hover:bg-brand-900/20 rounded-lg transition-colors"
|
||||
title={t("view_public")}
|
||||
>
|
||||
<FileText size={16} />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/admin/legal/view?id=${page.id}`}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||
title={t("edit_page_tooltip")}
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(page.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
title={t("delete_page_tooltip")}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/app/dashboard/admin/legal/create/page.tsx
Normal file
9
src/app/dashboard/admin/legal/create/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import AdminLegalEditorClient from "../AdminLegalEditorClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Create Legal Page",
|
||||
};
|
||||
|
||||
export default function CreateLegalPage() {
|
||||
return <AdminLegalEditorClient mode="create" />;
|
||||
}
|
||||
9
src/app/dashboard/admin/legal/page.tsx
Normal file
9
src/app/dashboard/admin/legal/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import AdminLegalListClient from "./AdminLegalListClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Legal Pages Management",
|
||||
};
|
||||
|
||||
export default function AdminLegalPage() {
|
||||
return <AdminLegalListClient />;
|
||||
}
|
||||
31
src/app/dashboard/admin/legal/view/EditLegalPageClient.tsx
Normal file
31
src/app/dashboard/admin/legal/view/EditLegalPageClient.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import useSWR from "swr";
|
||||
import axios from "@/lib/axios";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import AdminLegalEditorClient from "../AdminLegalEditorClient";
|
||||
|
||||
export default function EditLegalPageClient({ id }: { id: string }) {
|
||||
const t = useTranslations("LegalAdmin");
|
||||
const { data, error, isLoading } = useSWR(
|
||||
id ? `/api/admin/legal-pages/${id}` : null,
|
||||
(url) => axios.get(url).then((res) => res.data)
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
return <div className="p-10 text-center text-red-500">{t("invalid_id")}</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader text={t("loading_editor")} />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <div className="p-10 text-center text-red-500">{t("view_error")}</div>;
|
||||
}
|
||||
|
||||
return <AdminLegalEditorClient mode="edit" initialData={data.data} />;
|
||||
}
|
||||
23
src/app/dashboard/admin/legal/view/page.tsx
Normal file
23
src/app/dashboard/admin/legal/view/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense } from 'react';
|
||||
import EditLegalPageClient from './EditLegalPageClient';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
function EditLegalPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const id = searchParams.get('id');
|
||||
// If id is null, we might be in creation mode or invalid.
|
||||
// EditLegalPageClient seems to require id, so handle gracefully if needed or pass as is.
|
||||
return <EditLegalPageClient id={id || ''} />;
|
||||
}
|
||||
|
||||
export default function EditLegalPage() {
|
||||
const t = useTranslations("LegalAdmin");
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center p-10">{t("loading_editor")}</div>}>
|
||||
<EditLegalPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
102
src/app/dashboard/admin/root-ca/RootCaManagementClient.tsx
Normal file
102
src/app/dashboard/admin/root-ca/RootCaManagementClient.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import axios from "@/lib/axios";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import RootCaTable from "@/components/admin/RootCaTable";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import ConfirmationModal from "@/components/common/ConfirmationModal";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
|
||||
|
||||
export default function RootCaManagementClient() {
|
||||
const t = useTranslations("RootCA");
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const { addToast } = useToast();
|
||||
const { data, error, mutate, isLoading } = useSWR("/api/admin/ca-certificates", fetcher);
|
||||
const [isRenewing, setIsRenewing] = useState(false);
|
||||
const [confirmRenewUuid, setConfirmRenewUuid] = useState<string | null>(null);
|
||||
|
||||
// Redirect if not admin (double security, backend also checks)
|
||||
React.useEffect(() => {
|
||||
if (user && user.role !== "admin") {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
const handleRenew = async (uuid: string) => {
|
||||
setIsRenewing(true);
|
||||
try {
|
||||
await axios.post(`/api/admin/ca-certificates/${uuid}/renew`, {
|
||||
days: 3650
|
||||
});
|
||||
mutate();
|
||||
addToast(t("toast_renew_success"), "success");
|
||||
setConfirmRenewUuid(null);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
addToast(err.response?.data?.message || t("toast_renew_failed"), "error");
|
||||
} finally {
|
||||
setIsRenewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) return <div className="p-10 text-center text-error-500">{t("load_failed")}</div>;
|
||||
|
||||
const certificates = data?.data || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle={t("management_title")} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<ComponentCard
|
||||
title={t("card_title")}
|
||||
desc={t("card_desc")}
|
||||
className="relative"
|
||||
>
|
||||
<RootCaTable
|
||||
certificates={certificates}
|
||||
onRenew={setConfirmRenewUuid}
|
||||
isRenewing={isRenewing}
|
||||
/>
|
||||
|
||||
{(isLoading || isRenewing) && (
|
||||
<div className="absolute inset-0 bg-white/50 dark:bg-gray-900/50 flex items-center justify-center z-10 backdrop-blur-sm rounded-2xl">
|
||||
<PageLoader text={t("processing")} className="h-full" />
|
||||
</div>
|
||||
)}
|
||||
</ComponentCard>
|
||||
|
||||
<div className="p-4 bg-blue-50 border-l-4 border-blue-400 dark:bg-blue-900/20 dark:border-blue-600 rounded-md">
|
||||
<h4 className="text-sm font-bold text-blue-800 dark:text-blue-200">{t("info_title")}</h4>
|
||||
<ul className="mt-2 text-xs text-blue-700 dark:text-blue-300 list-disc list-inside space-y-1">
|
||||
<li>{t("info_point_1")}</li>
|
||||
<li>{t("info_point_2")}</li>
|
||||
<li>{t("info_point_3")}</li>
|
||||
<li>{t("info_point_4")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={confirmRenewUuid !== null}
|
||||
onClose={() => setConfirmRenewUuid(null)}
|
||||
onConfirm={() => confirmRenewUuid && handleRenew(confirmRenewUuid)}
|
||||
title={t("renew_modal_title")}
|
||||
message={t("renew_modal_msg")}
|
||||
isLoading={isRenewing}
|
||||
confirmLabel={t("renew_modal_confirm")}
|
||||
variant="warning"
|
||||
requiredInput="RENEW"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/dashboard/admin/root-ca/page.tsx
Normal file
11
src/app/dashboard/admin/root-ca/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import RootCaManagementClient from "./RootCaManagementClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Root CA Management",
|
||||
description: "Manage your Certification Authorities, including Root and Intermediate certificates.",
|
||||
};
|
||||
|
||||
export default function RootCaManagementPage() {
|
||||
return <RootCaManagementClient />;
|
||||
}
|
||||
221
src/app/dashboard/admin/smtp-tester/page.tsx
Normal file
221
src/app/dashboard/admin/smtp-tester/page.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Mail, Send, CheckCircle2, XCircle, Settings, ShieldCheck, MailWarning, Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import axios from '@/lib/axios';
|
||||
import { useToast } from '@/context/ToastContext';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import Button from '@/components/ui/button/Button';
|
||||
import PageBreadcrumb from '@/components/common/PageBreadCrumb';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface MailConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
encryption: string;
|
||||
from: string;
|
||||
}
|
||||
|
||||
interface Configs {
|
||||
smtp: MailConfig;
|
||||
support: MailConfig;
|
||||
}
|
||||
|
||||
export default function SmtpTesterPage() {
|
||||
const t = useTranslations('SmtpTester');
|
||||
const router = useRouter();
|
||||
const { user } = useAuth({ middleware: 'auth' });
|
||||
const { addToast } = useToast();
|
||||
const [configs, setConfigs] = useState<Configs | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [testLoading, setTestLoading] = useState<string | null>(null); // 'smtp' or 'support'
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
const [results, setResults] = useState<{ [key: string]: { success: boolean; message: string } }>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.role !== 'admin') {
|
||||
router.push('/dashboard/settings');
|
||||
return;
|
||||
}
|
||||
fetchConfigs();
|
||||
}, [user, router]);
|
||||
|
||||
const fetchConfigs = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/admin/smtp/config');
|
||||
setConfigs(response.data);
|
||||
} catch (error) {
|
||||
addToast(t('toast_fetch_failed'), 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runTest = async (mailer: 'smtp' | 'support') => {
|
||||
if (!testEmail) {
|
||||
addToast(t('toast_email_req'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setTestLoading(mailer);
|
||||
setResults(prev => ({ ...prev, [mailer]: undefined as any }));
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/admin/smtp/test', {
|
||||
email: testEmail,
|
||||
mailer
|
||||
});
|
||||
|
||||
setResults(prev => ({
|
||||
...prev,
|
||||
[mailer]: { success: true, message: response.data.message }
|
||||
}));
|
||||
addToast(t('toast_test_success', { mailer }), 'success');
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || t('err_conn_failed');
|
||||
setResults(prev => ({
|
||||
...prev,
|
||||
[mailer]: { success: false, message }
|
||||
}));
|
||||
addToast(t('toast_test_error', { message }), 'error');
|
||||
} finally {
|
||||
setTestLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Breadcrumb & Header */}
|
||||
<PageBreadcrumb pageTitle={t('page_title')} />
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Recipient Input */}
|
||||
<div className="p-6 bg-white rounded-2xl border border-gray-100 dark:bg-gray-900 dark:border-gray-800 shadow-sm">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('recipient_label')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
placeholder={t('recipient_placeholder')}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-200 rounded-xl bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mailer Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{(['smtp', 'support'] as const).map((mailer) => (
|
||||
<div
|
||||
key={mailer}
|
||||
className="flex flex-col p-6 bg-white rounded-2xl border border-gray-100 dark:bg-gray-900 dark:border-gray-800 shadow-sm overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-xl shrink-0 ${mailer === 'smtp' ? 'bg-blue-50 text-blue-600 dark:bg-blue-500/10' : 'bg-purple-50 text-purple-600 dark:bg-purple-500/10'}`}>
|
||||
<Settings className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white capitalize">{t('mailer_title', { mailer })}</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{t('outgoing_settings')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{results[mailer] && (
|
||||
results[mailer].success ? (
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-emerald-600 bg-emerald-50 dark:bg-emerald-500/10 px-2.5 py-1 rounded-full">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
{t('status_passed')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-rose-600 bg-rose-50 dark:bg-rose-500/10 px-2.5 py-1 rounded-full">
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
{t('status_failed')}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 mb-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] uppercase tracking-wider font-bold text-gray-400 dark:text-gray-500">{t('host_label')}</p>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">{configs?.[mailer].host || t('not_set')}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] uppercase tracking-wider font-bold text-gray-400 dark:text-gray-500">{t('port_security_label')}</p>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{configs?.[mailer].port} / <span className="uppercase">{configs?.[mailer].encryption}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] uppercase tracking-wider font-bold text-gray-400 dark:text-gray-500">{t('from_label')}</p>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">{configs?.[mailer].from || t('not_set')}</p>
|
||||
</div>
|
||||
|
||||
{results[mailer] && !results[mailer].success && (
|
||||
<div className="p-3 bg-rose-50 dark:bg-rose-500/5 rounded-xl border border-rose-100 dark:border-rose-500/20">
|
||||
<div className="flex gap-2">
|
||||
<MailWarning className="w-4 h-4 text-rose-500 shrink-0" />
|
||||
<p className="text-xs text-rose-600 dark:text-rose-400 leading-relaxed">
|
||||
{results[mailer].message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => runTest(mailer)}
|
||||
loading={testLoading === mailer}
|
||||
className="w-full justify-center"
|
||||
variant={results[mailer]?.success ? 'success' : 'primary'}
|
||||
startIcon={<Send className="w-4 h-4" />}
|
||||
>
|
||||
{t('send_test')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Information Card */}
|
||||
<div className="p-6 bg-blue-50/50 rounded-2xl border border-blue-100 dark:bg-blue-500/5 dark:border-blue-500/20">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-blue-100 rounded-xl dark:bg-blue-500/20 text-blue-600 shrink-0">
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-blue-900 dark:text-blue-200">{t('system_info_title')}</h4>
|
||||
<p className="text-sm text-blue-800/70 dark:text-blue-300/60 mt-1 leading-relaxed">
|
||||
{t('system_info_desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
403
src/app/dashboard/admin/tickets/AdminTicketDetailsClient.tsx
Normal file
403
src/app/dashboard/admin/tickets/AdminTicketDetailsClient.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import useSWR from "swr";
|
||||
import axios from "@/lib/axios";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { Send, ArrowLeft, Clock, CheckCircle, AlertCircle, User, MessageSquare, XCircle, Mail, Paperclip, FileText, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import Link from "next/link";
|
||||
import { getUserAvatar, parseApiError, getAttachmentUrl } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
|
||||
import ConfirmationModal from "@/components/common/ConfirmationModal";
|
||||
|
||||
const statusColors: any = {
|
||||
open: "bg-blue-100 text-blue-800 dark:bg-blue-500/10 dark:text-blue-400 border border-blue-200 dark:border-blue-800/20",
|
||||
answered: "bg-green-100 text-green-800 dark:bg-green-500/10 dark:text-green-400 border border-green-200 dark:border-green-800/20",
|
||||
closed: "bg-gray-100 text-gray-800 dark:bg-gray-500/10 dark:text-gray-400 border border-gray-200 dark:border-gray-800/20",
|
||||
};
|
||||
|
||||
const priorityColors: any = {
|
||||
low: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
||||
medium: "bg-orange-100 text-orange-700 dark:bg-orange-500/10 dark:text-orange-400",
|
||||
high: "bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400",
|
||||
};
|
||||
|
||||
export default function AdminTicketDetailsClient() {
|
||||
const t = useTranslations("Tickets");
|
||||
const searchParams = useSearchParams();
|
||||
const ticketId = searchParams.get("id");
|
||||
const { user } = useAuth();
|
||||
const { addToast } = useToast();
|
||||
const [replyMessage, setReplyMessage] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isCloseModalOpen, setIsCloseModalOpen] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: ticket, error, isLoading, mutate } = useSWR(
|
||||
ticketId ? `/api/support/tickets/${ticketId}` : null,
|
||||
(url) => axios.get(url).then((res) => res.data)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [ticket?.replies]);
|
||||
|
||||
const handleReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!replyMessage.trim() && selectedFiles.length === 0) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
const formData = new FormData();
|
||||
formData.append("message", replyMessage);
|
||||
selectedFiles.forEach((file) => formData.append("attachments[]", file));
|
||||
|
||||
try {
|
||||
await axios.post(`/api/support/tickets/${ticketId}/reply`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
setReplyMessage("");
|
||||
setSelectedFiles([]);
|
||||
mutate();
|
||||
addToast(t("toast_reply_sent"), "success");
|
||||
} catch (err: any) {
|
||||
addToast(parseApiError(err, t("toast_reply_failed")), "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseTicket = async () => {
|
||||
try {
|
||||
await axios.patch(`/api/support/tickets/${ticketId}/close`);
|
||||
addToast(t("toast_closed"), "success");
|
||||
setIsCloseModalOpen(false);
|
||||
mutate();
|
||||
} catch (err: any) {
|
||||
addToast(parseApiError(err, t("toast_close_failed")), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const filesArray = Array.from(e.target.files);
|
||||
if (selectedFiles.length + filesArray.length > 5) {
|
||||
addToast(t("toast_max_files"), "error");
|
||||
return;
|
||||
}
|
||||
setSelectedFiles((prev) => [...prev, ...filesArray]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader text={t("syncing_history")} />;
|
||||
}
|
||||
|
||||
if (error?.response?.status === 404 || !ticket) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100 dark:border-gray-800 shadow-theme-xl max-w-2xl mx-auto">
|
||||
<div className="w-20 h-20 bg-gray-50 dark:bg-white/5 rounded-full flex items-center justify-center mb-6">
|
||||
<AlertCircle size={40} className="text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">{t("not_found_title")}</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center max-w-md px-6 mb-8">
|
||||
{t("not_found_desc")}
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/admin/tickets"
|
||||
className="flex items-center gap-2 px-6 py-3 bg-brand-500 text-white rounded-xl hover:bg-brand-600 transition-all shadow-theme-md font-bold"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
{t("back_to_list")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-6xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<Link
|
||||
href="/dashboard/admin/tickets"
|
||||
className="flex items-center gap-2 text-gray-500 hover:text-brand-500 transition-colors font-medium group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-gray-100 dark:bg-white/5 flex items-center justify-center group-hover:bg-brand-500 group-hover:text-white transition-all">
|
||||
<ArrowLeft size={18} />
|
||||
</div>
|
||||
{t("back_to_list")}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{ticket.status !== 'closed' && (
|
||||
<button
|
||||
onClick={() => setIsCloseModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-red-200 dark:border-red-900/30 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/5 rounded-lg hover:bg-red-100 transition-colors shadow-theme-xs font-bold text-sm"
|
||||
>
|
||||
<XCircle size={18} />
|
||||
{t("close_ticket")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Conversation Column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<ComponentCard title={t("discussion_title")} className="p-0 overflow-hidden border-none shadow-theme-xl">
|
||||
{/* Conversations Area */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-[600px] overflow-y-auto p-6 space-y-6 flex flex-col custom-scrollbar bg-gray-50 dark:bg-white/2"
|
||||
>
|
||||
{ticket.replies?.map((reply: any) => {
|
||||
const isMe = reply.user_id === user?.id;
|
||||
const isCustomer = reply.user_id === ticket.user_id;
|
||||
const isOtherAdmin = !isMe && !isCustomer && (reply.user?.role === 'admin' || reply.user?.role === 'owner');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={reply.id}
|
||||
className={`flex gap-3 max-w-[85%] ${isMe ? 'self-end flex-row-reverse' : 'self-start'}`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Image
|
||||
width={40}
|
||||
height={40}
|
||||
src={getUserAvatar(reply.user)}
|
||||
alt={reply.user?.first_name ? `${reply.user.first_name} ${reply.user.last_name}` : "User"}
|
||||
className="rounded-full shadow-sm"
|
||||
unoptimized={true}
|
||||
/>
|
||||
</div>
|
||||
<div className={`space-y-1 ${isMe ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`flex items-center gap-2 px-2 ${isMe ? 'flex-row-reverse' : 'flex-row'}`}>
|
||||
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-widest">
|
||||
{reply.user?.first_name} {reply.user?.last_name} {(reply.user?.role === 'admin' || reply.user?.role === 'owner') && <span className="text-brand-500 ml-1">({t("admin_badge")})</span>}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{new Date(reply.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`p-4 rounded-2xl text-sm leading-relaxed shadow-theme-xs border ${
|
||||
isMe
|
||||
? 'bg-brand-500 text-white rounded-tr-none border-brand-600'
|
||||
: isOtherAdmin
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-900 dark:text-blue-300 rounded-tl-none border border-blue-100 dark:border-blue-800/30'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-tl-none border border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
{reply.message}
|
||||
|
||||
{/* Attachments Display */}
|
||||
{reply.attachments && reply.attachments.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{reply.attachments.map((att: any) => (
|
||||
<a
|
||||
key={att.id}
|
||||
href={getAttachmentUrl(att.file_path)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center gap-2 p-2 rounded-lg text-xs transition-colors ${
|
||||
isMe
|
||||
? 'bg-white/10 hover:bg-white/20 text-white'
|
||||
: 'bg-white dark:bg-white/5 hover:bg-gray-50 dark:hover:bg-white/10 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span className="truncate max-w-[150px]">{att.file_name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Admin Reply Toolbox */}
|
||||
{ticket.status !== 'closed' ? (
|
||||
<div className="p-6 bg-white dark:bg-gray-dark border-t border-gray-100 dark:border-gray-800">
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{selectedFiles.map((file, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 px-3 py-1 bg-gray-100 dark:bg-white/5 rounded-full text-xs text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-800">
|
||||
<span className="truncate max-w-[100px]">{file.name}</span>
|
||||
<button onClick={() => removeFile(idx)} className="text-gray-400 hover:text-red-500">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleReply} className="relative">
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder={t("reply_placeholder")}
|
||||
className="w-full p-4 pr-16 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-2xl focus:ring-2 focus:ring-brand-500 resize-none transition-all placeholder:text-gray-400 dark:placeholder:text-white/20 font-medium text-gray-900 dark:text-white"
|
||||
value={replyMessage}
|
||||
onChange={(e) => setReplyMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
handleReply(e as any);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute right-3 bottom-3 flex gap-2">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".jpg,.jpeg,.png,.pdf,.doc,.docx,.zip,.txt"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-3 bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-xl hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
|
||||
title={t("attach_files")}
|
||||
>
|
||||
<Paperclip size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || (!replyMessage.trim() && selectedFiles.length === 0)}
|
||||
className="p-3 bg-brand-500 text-white rounded-xl hover:bg-brand-600 transition-all shadow-theme-md disabled:bg-gray-200 dark:disabled:bg-gray-800 disabled:text-gray-400"
|
||||
>
|
||||
<Send size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-3 flex justify-between items-center text-[10px] text-gray-400">
|
||||
<span className="italic">{t("reply_hint")}</span>
|
||||
<span className="font-bold text-brand-500 uppercase">{t("replying_as_admin")}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-10 text-center bg-gray-100 dark:bg-white/5 border-t border-gray-200 dark:border-gray-800">
|
||||
<div className="w-16 h-16 bg-white dark:bg-gray-900 rounded-full flex items-center justify-center mx-auto mb-4 shadow-theme-lg border border-gray-200 dark:border-gray-700">
|
||||
<XCircle size={32} className="text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white uppercase tracking-widest">{t("locked_title")}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-xs mx-auto">{t("locked_desc")}</p>
|
||||
</div>
|
||||
)}
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Info Column */}
|
||||
<div className="space-y-6">
|
||||
<ComponentCard title={t("overview_title")}>
|
||||
<div className="space-y-6">
|
||||
<div className="pb-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<span className="text-[10px] uppercase font-black text-gray-400 tracking-widest mb-1 block">{t("subject_label")}</span>
|
||||
<p className="font-bold text-gray-900 dark:text-white leading-tight">{ticket.subject}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-black text-gray-400 tracking-widest mb-1 block">{t("status_th")}</span>
|
||||
<div className={`inline-flex px-3 py-1 rounded-full text-[10px] font-black uppercase ${statusColors[ticket.status]}`}>
|
||||
{t(`status_${ticket.status}`)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-black text-gray-400 tracking-widest mb-1 block">{t("priority_th")}</span>
|
||||
<div className={`inline-flex px-3 py-1 rounded-full text-[10px] font-black uppercase ${priorityColors[ticket.priority]}`}>
|
||||
{t(`priority_${ticket.priority}`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pb-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-black text-gray-400 tracking-widest mb-1 block">{t("category_label")}</span>
|
||||
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 capitalize">{ticket.category}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-black text-gray-400 tracking-widest mb-1 block">{t("reference_label")}</span>
|
||||
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 uppercase">#{ticket.ticket_number}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
<ComponentCard title={t("profile_title")}>
|
||||
<div className="flex flex-col items-center text-center p-4">
|
||||
<div className="w-20 h-20 bg-gray-100 dark:bg-white/5 rounded-full p-1 mb-4 border-2 border-brand-500/20">
|
||||
<Image
|
||||
width={80}
|
||||
height={80}
|
||||
src={getUserAvatar(ticket.user)}
|
||||
alt={ticket.user?.first_name ? `${ticket.user.first_name} ${ticket.user.last_name}` : "User"}
|
||||
className="rounded-full shadow-theme-md"
|
||||
unoptimized={true}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white leading-none mb-1">
|
||||
{ticket.user ? `${ticket.user.first_name} ${ticket.user.last_name}` : t("unknown_user")}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 mb-6 font-medium">{ticket.user?.email || t("no_email")}</span>
|
||||
|
||||
<div className="w-full flex gap-3">
|
||||
{ticket.user ? (
|
||||
<>
|
||||
<Link
|
||||
href={`/dashboard/users/view?id=${ticket.user.id}`}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3 bg-gray-50 dark:bg-white/5 text-xs font-black uppercase tracking-widest rounded-xl hover:bg-gray-100 dark:hover:bg-white/10 transition-all border border-gray-100 dark:border-gray-800 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<User size={14} /> {t("profile_button")}
|
||||
</Link>
|
||||
<a
|
||||
href={`mailto:${ticket.user.email}`}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3 bg-gray-50 dark:bg-white/5 text-xs font-black uppercase tracking-widest rounded-xl hover:bg-gray-100 dark:hover:bg-white/10 transition-all border border-gray-100 dark:border-gray-800 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<Mail size={14} /> {t("email_button")}
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
disabled
|
||||
className="w-full flex items-center justify-center gap-2 py-3 bg-gray-50 dark:bg-white/5 text-xs font-black uppercase tracking-widest rounded-xl opacity-50 cursor-not-allowed border border-gray-100 dark:border-gray-800 text-gray-400"
|
||||
>
|
||||
{t("user_not_available")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={isCloseModalOpen}
|
||||
onClose={() => setIsCloseModalOpen(false)}
|
||||
onConfirm={handleCloseTicket}
|
||||
title={t("modal_lock_title")}
|
||||
message={t("modal_lock_msg")}
|
||||
confirmLabel={t("modal_lock_confirm")}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
src/app/dashboard/admin/tickets/AdminTicketListClient.tsx
Normal file
161
src/app/dashboard/admin/tickets/AdminTicketListClient.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import axios from "@/lib/axios";
|
||||
import { Search, MessageSquare, Clock, Filter, ChevronRight, User as UserIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import Link from "next/link";
|
||||
import { getUserAvatar } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
|
||||
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
|
||||
|
||||
const statusColors: any = {
|
||||
open: "bg-blue-100 text-blue-800 dark:bg-blue-500/10 dark:text-blue-400",
|
||||
answered: "bg-green-100 text-green-800 dark:bg-green-500/10 dark:text-green-400",
|
||||
closed: "bg-gray-100 text-gray-800 dark:bg-gray-500/10 dark:text-gray-400",
|
||||
};
|
||||
|
||||
const priorityColors: any = {
|
||||
low: "bg-gray-100 text-gray-800 dark:bg-gray-500/10 dark:text-gray-400",
|
||||
medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-500/10 dark:text-yellow-400",
|
||||
high: "bg-red-100 text-red-800 dark:bg-red-500/10 dark:text-red-400",
|
||||
};
|
||||
|
||||
export default function AdminTicketListClient() {
|
||||
const t = useTranslations("Tickets");
|
||||
const { addToast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const { data, mutate, isLoading } = useSWR("/api/support/tickets?all=true", fetcher);
|
||||
|
||||
const tickets = data?.data || [];
|
||||
const filteredTickets = tickets.filter((t: any) => {
|
||||
const matchesSearch = t.subject.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
t.ticket_number.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
`${t.user?.first_name} ${t.user?.last_name}`.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === "all" || t.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageBreadcrumb pageTitle={t("management_title")} />
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<Search size={18} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("search_placeholder")}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-800 rounded-lg focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 text-gray-900 dark:text-white"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="px-4 py-2 border border-gray-200 dark:border-gray-800 rounded-lg focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 text-gray-900 dark:text-white"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">{t("all_status")}</option>
|
||||
<option value="open">{t("status_open")}</option>
|
||||
<option value="answered">{t("status_answered")}</option>
|
||||
<option value="closed">{t("status_closed")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-500">
|
||||
{t("total_tickets", { count: filteredTickets.length })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ComponentCard className="p-0 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-50 dark:bg-white/2 border-b border-gray-100 dark:border-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">{t("ticket_user_th")}</th>
|
||||
<th className="px-6 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">{t("subject_th")}</th>
|
||||
<th className="px-6 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">{t("priority_th")}</th>
|
||||
<th className="px-6 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">{t("status_th")}</th>
|
||||
<th className="px-6 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">{t("created_at_th")}</th>
|
||||
<th className="px-6 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider text-right">{t("action_th")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-20 text-center">
|
||||
<PageLoader text={t("syncing")} className="py-10" />
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredTickets.length > 0 ? (
|
||||
filteredTickets.map((ticket: any) => (
|
||||
<tr key={ticket.id} className="group hover:bg-gray-50 dark:hover:bg-white/2 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Image
|
||||
width={36}
|
||||
height={36}
|
||||
src={getUserAvatar(ticket.user)}
|
||||
alt={ticket.user?.first_name ? `${ticket.user.first_name} ${ticket.user.last_name}` : "User"}
|
||||
className="rounded-full shadow-sm"
|
||||
unoptimized={true}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-gray-400 uppercase tracking-wider">#{ticket.ticket_number}</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{ticket.user?.first_name} {ticket.user?.last_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="max-w-xs overflow-hidden">
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white truncate">{ticket.subject}</p>
|
||||
<p className="text-xs text-gray-500 capitalize">{ticket.category}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase ${priorityColors[ticket.priority]}`}>
|
||||
{t(`priority_${ticket.priority}`)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase ${statusColors[ticket.status]}`}>
|
||||
{t(`status_${ticket.status}`)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
|
||||
{new Date(ticket.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<Link
|
||||
href={`/dashboard/admin/tickets/view?id=${ticket.id}`}
|
||||
className="inline-flex items-center gap-1 text-xs font-bold text-brand-500 hover:text-brand-600 dark:text-brand-400 transition-colors uppercase tracking-widest"
|
||||
>
|
||||
{t("manage")} <ChevronRight size={14} />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-20 text-center">
|
||||
<p className="text-gray-500 font-medium italic">{t("no_tickets_found")}</p>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/dashboard/admin/tickets/page.tsx
Normal file
11
src/app/dashboard/admin/tickets/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import AdminTicketListClient from "./AdminTicketListClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Ticket Management",
|
||||
description: "Manage and reply to customer support tickets.",
|
||||
};
|
||||
|
||||
export default function AdminTicketsPage() {
|
||||
return <AdminTicketListClient />;
|
||||
}
|
||||
16
src/app/dashboard/admin/tickets/view/page.tsx
Normal file
16
src/app/dashboard/admin/tickets/view/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import AdminTicketDetailsClient from "../AdminTicketDetailsClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Support Ticket Management",
|
||||
description: "View and resolve customer support tickets.",
|
||||
};
|
||||
|
||||
export default function AdminTicketDetailsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-center py-20">Loading Ticket Details...</div>}>
|
||||
<AdminTicketDetailsClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
262
src/app/dashboard/admin/users/UsersManagementClient.tsx
Normal file
262
src/app/dashboard/admin/users/UsersManagementClient.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import axios from "@/lib/axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import { PlusIcon } from "@/icons";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import ConfirmationModal from "@/components/common/ConfirmationModal";
|
||||
import UserModal from "@/components/users/UserModal";
|
||||
import { Edit, Trash, MoreVertical, Shield, User as UserIcon, Mail, Calendar, Search } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { getUserAvatar } from "@/lib/utils";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
|
||||
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
|
||||
|
||||
export default function UsersManagementClient() {
|
||||
const t = useTranslations("Users");
|
||||
const { user: currentUser } = useAuth();
|
||||
const { addToast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("all");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<any>(null);
|
||||
|
||||
const { data, error, mutate, isLoading } = useSWR("/api/admin/users", fetcher);
|
||||
|
||||
const hasAdminAccess = ['admin', 'owner'].includes(currentUser?.role || '');
|
||||
const isOwner = currentUser?.role === "owner";
|
||||
const isAdmin = currentUser?.role === "admin";
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await axios.delete(`/api/admin/users/${id}`);
|
||||
mutate();
|
||||
addToast(t("toast_deleted"), "success");
|
||||
setConfirmDeleteId(null);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
addToast(err.response?.data?.message || t("toast_delete_failed"), "error");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveUser = async (formData: any) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (editingUser) {
|
||||
await axios.patch(`/api/admin/users/${editingUser.id}`, formData);
|
||||
addToast(t("toast_updated"), "success");
|
||||
} else {
|
||||
await axios.post("/api/admin/users", formData);
|
||||
addToast(t("toast_created"), "success");
|
||||
}
|
||||
mutate();
|
||||
setIsModalOpen(false);
|
||||
setEditingUser(null);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
addToast(err.response?.data?.message || t("toast_save_failed"), "error");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditClick = (u: any) => {
|
||||
setEditingUser(u);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAddClick = () => {
|
||||
setEditingUser(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const users = data?.data || [];
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
return users.filter((u: any) => {
|
||||
const fullName = `${u.first_name || ''} ${u.last_name || ''}`.toLowerCase();
|
||||
const matchesSearch =
|
||||
fullName.includes(searchTerm.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesRole = roleFilter === "all" || u.role === roleFilter;
|
||||
return matchesSearch && matchesRole;
|
||||
});
|
||||
}, [users, searchTerm, roleFilter]);
|
||||
|
||||
if (error) return <div className="p-10 text-center text-error-500">{t("load_failed")}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle={t("management_title")} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<ComponentCard
|
||||
title={t("dashboard_title")}
|
||||
desc={t("dashboard_desc")}
|
||||
headerAction={
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 mt-4 sm:mt-0">
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<Search className="w-4 h-4" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-800 rounded-lg bg-transparent focus:ring-2 focus:ring-brand-500/10 outline-none w-full sm:w-64 dark:text-white/90 dark:placeholder:text-white/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-gray-200 dark:border-gray-800 rounded-lg bg-white dark:bg-gray-900 outline-none dark:text-white/90 w-full sm:w-auto cursor-pointer"
|
||||
>
|
||||
<option value="all" className="bg-white dark:bg-gray-900">{t("all_roles")}</option>
|
||||
{isOwner && <option value="owner" className="bg-white dark:bg-gray-900">Owner</option>}
|
||||
<option value="admin" className="bg-white dark:bg-gray-900">{t("admins")}</option>
|
||||
<option value="customer" className="bg-white dark:bg-gray-900">{t("customers")}</option>
|
||||
</select>
|
||||
{hasAdminAccess && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 justify-center w-full sm:w-auto whitespace-nowrap"
|
||||
onClick={handleAddClick}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
{t("add_user")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="relative overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-gray-800">
|
||||
<th className="px-5 py-4 text-xs font-bold text-gray-500 uppercase tracking-widest">{t("user_th")}</th>
|
||||
<th className="px-5 py-4 text-xs font-bold text-gray-500 uppercase tracking-widest">{t("role_th")}</th>
|
||||
<th className="px-5 py-4 text-xs font-bold text-gray-500 uppercase tracking-widest">{t("joined_date_th")}</th>
|
||||
<th className="px-5 py-4 text-xs font-bold text-gray-500 uppercase tracking-widest text-right">{t("actions_th")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50 dark:divide-gray-800/50">
|
||||
{filteredUsers.length > 0 ? (
|
||||
filteredUsers.map((u: any) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50/50 dark:hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-10 h-10 rounded-full overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<Image
|
||||
src={getUserAvatar(u)}
|
||||
alt={`${u.first_name} ${u.last_name}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-gray-900 dark:text-white">{u.first_name} {u.last_name}</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<Mail className="w-3 h-3" /> {u.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest ${u.role === 'admin' || u.role === 'owner' ? 'bg-brand-50 text-brand-600 dark:bg-brand-500/10 dark:text-brand-400' : 'bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400'}`}>
|
||||
{u.role === 'admin' || u.role === 'owner' ? <Shield className="w-3 h-3" /> : <UserIcon className="w-3 h-3" />}
|
||||
{u.role === 'customer' ? t("customers").toLowerCase() : u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 flex items-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
{new Date(u.created_at).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{(isOwner || (isAdmin && u.role === 'customer')) && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleEditClick(u)}
|
||||
className="p-2 text-gray-400 hover:text-brand-500 transition-colors"
|
||||
title={t("edit_user")}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteId(u.id)}
|
||||
className={`p-2 text-gray-400 hover:text-red-500 transition-colors ${currentUser?.id === u.id ? 'opacity-20 pointer-events-none' : ''}`}
|
||||
title={t("delete_user")}
|
||||
>
|
||||
<Trash className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : !isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-5 py-10 text-center text-gray-500 dark:text-gray-400 italic">
|
||||
{t("no_users_found")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{(isLoading || isDeleting) && (
|
||||
<div className="absolute inset-0 bg-white/50 dark:bg-gray-900/50 flex items-center justify-center z-10 backdrop-blur-sm rounded-2xl">
|
||||
<PageLoader text={t("processing")} className="h-full" />
|
||||
</div>
|
||||
)}
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={confirmDeleteId !== null}
|
||||
onClose={() => setConfirmDeleteId(null)}
|
||||
onConfirm={() => confirmDeleteId && handleDelete(confirmDeleteId)}
|
||||
title={t("delete_title")}
|
||||
message={t("delete_message")}
|
||||
isLoading={isDeleting}
|
||||
confirmLabel={t("delete_confirm")}
|
||||
requiredInput="DELETE"
|
||||
/>
|
||||
|
||||
<UserModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSaveUser}
|
||||
user={editingUser}
|
||||
isLoading={isSaving}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/dashboard/admin/users/page.tsx
Normal file
11
src/app/dashboard/admin/users/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import UsersManagementClient from "./UsersManagementClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "User Management",
|
||||
description: "Manage users, roles, and permissions within TrustLab.",
|
||||
};
|
||||
|
||||
export default function UserManagementPage() {
|
||||
return <UsersManagementClient />;
|
||||
}
|
||||
21
src/app/dashboard/api-keys/ApiKeysClient.tsx
Normal file
21
src/app/dashboard/api-keys/ApiKeysClient.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import ApiKeyManagement from "@/components/api-keys/ApiKeyManagement";
|
||||
import ApiUsageDocs from "@/components/ApiUsageDocs";
|
||||
import PageBreadCrumb from "@/components/common/PageBreadCrumb";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function ApiKeysClient() {
|
||||
const t = useTranslations("ApiKeys");
|
||||
return (
|
||||
<div className="mx-auto max-w-(--breakpoint-2xl) p-4 md:p-6 lg:p-10">
|
||||
<PageBreadCrumb pageTitle={t("page_title")} />
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
<ApiKeyManagement />
|
||||
<ApiUsageDocs />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/dashboard/api-keys/page.tsx
Normal file
11
src/app/dashboard/api-keys/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import ApiKeysClient from "./ApiKeysClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "API Keys",
|
||||
description: "Manage your API access tokens.",
|
||||
};
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
return <ApiKeysClient />;
|
||||
}
|
||||
154
src/app/dashboard/certificates/CertificatesClient.tsx
Normal file
154
src/app/dashboard/certificates/CertificatesClient.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import axios from "@/lib/axios";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import CertificateTable from "@/components/certificates/CertificateTable";
|
||||
import { PlusIcon } from "@/icons";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import ConfirmationModal from "@/components/common/ConfirmationModal";
|
||||
import CreateCertificateModal from "@/components/certificates/CreateCertificateModal";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
|
||||
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
|
||||
|
||||
export default function CertificatesClient() {
|
||||
const { user } = useAuth();
|
||||
const t = useTranslations("Certificates");
|
||||
const { addToast } = useToast();
|
||||
const { data, error, mutate, isLoading } = useSWR("/api/certificates", fetcher);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isSettingUp, setIsSettingUp] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [confirmDeleteUuid, setConfirmDeleteUuid] = useState<string | null>(null);
|
||||
|
||||
const isAdmin = user?.role === "admin" || user?.role === "owner";
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await axios.delete(`/api/certificates/${uuid}`);
|
||||
mutate();
|
||||
addToast(t("toast_deleted"), "success");
|
||||
setConfirmDeleteUuid(null);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
addToast(err.response?.data?.message || t("toast_delete_failed"), "error");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetupCa = async () => {
|
||||
setIsSettingUp(true);
|
||||
try {
|
||||
await axios.post("/api/ca/setup");
|
||||
mutate();
|
||||
addToast(t("toast_ca_setup_success"), "success");
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
addToast(err.response?.data?.message || t("toast_ca_setup_failed"), "error");
|
||||
} finally {
|
||||
setIsSettingUp(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) return <div className="p-10 text-center text-error-500">{t("load_failed")}</div>;
|
||||
|
||||
const certificates = data?.data?.data || [];
|
||||
const caStatus = data?.ca_status;
|
||||
const caReady = caStatus?.is_ready;
|
||||
|
||||
// Defaults for the modal
|
||||
const createDefaults = {
|
||||
organizationName: "TrustLab CA",
|
||||
localityName: "Jakarta",
|
||||
stateOrProvinceName: "DKI Jakarta",
|
||||
countryName: "ID"
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle={t("page_title")} />
|
||||
|
||||
<div className="space-y-6">
|
||||
{!caReady && !isLoading && (
|
||||
<div className="p-4 bg-yellow-50 border-l-4 border-yellow-400 dark:bg-yellow-900/20 dark:border-yellow-600 rounded-md flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-yellow-800 dark:text-yellow-200">
|
||||
{t("ca_not_setup_title")}
|
||||
</span>
|
||||
<span className="text-xs text-yellow-700 dark:text-yellow-300">
|
||||
{t("ca_missing_msg", { missing: caStatus?.missing?.join(", ").replace(/_/g, " ") })}
|
||||
</span>
|
||||
{!isAdmin && (
|
||||
<span className="mt-1 text-xs font-medium text-yellow-800 dark:text-yellow-200">
|
||||
{t("contact_admin_msg")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
onClick={handleSetupCa}
|
||||
size="sm"
|
||||
className="bg-yellow-600 hover:bg-yellow-700 text-white border-none h-auto whitespace-nowrap w-full sm:w-auto"
|
||||
loading={isSettingUp}
|
||||
>
|
||||
{t("btn_setup_ca")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ComponentCard
|
||||
title={t("management_title")}
|
||||
desc={t("management_desc")}
|
||||
headerAction={
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
disabled={!caReady}
|
||||
className={`flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white transition rounded-lg bg-brand-500 hover:bg-brand-600 ${!caReady ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
{t("btn_generate_new")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<CertificateTable
|
||||
certificates={certificates}
|
||||
onDelete={setConfirmDeleteUuid}
|
||||
/>
|
||||
|
||||
{(isLoading || isDeleting) && (
|
||||
<div className="absolute inset-0 bg-white/50 dark:bg-gray-900/50 flex items-center justify-center z-10 backdrop-blur-sm rounded-2xl">
|
||||
<PageLoader text={t("processing")} className="h-full" />
|
||||
</div>
|
||||
)}
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
<CreateCertificateModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSuccess={() => mutate()}
|
||||
defaults={createDefaults}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={confirmDeleteUuid !== null}
|
||||
onClose={() => setConfirmDeleteUuid(null)}
|
||||
onConfirm={() => confirmDeleteUuid && handleDelete(confirmDeleteUuid)}
|
||||
title={t("delete_title")}
|
||||
message={t("delete_msg")}
|
||||
isLoading={isDeleting}
|
||||
confirmLabel={t("delete_confirm")}
|
||||
requiredInput="DELETE"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/dashboard/certificates/page.tsx
Normal file
11
src/app/dashboard/certificates/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import CertificatesClient from "./CertificatesClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Certificates",
|
||||
description: "View and manage your SSL/TLS certificates.",
|
||||
};
|
||||
|
||||
export default function CertificatesPage() {
|
||||
return <CertificatesClient />;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import axios from "@/lib/axios";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import CertificateDetails from "@/components/certificates/CertificateDetails";
|
||||
|
||||
export default function CertificateDetailsClient() {
|
||||
const t = useTranslations("Certificates");
|
||||
const searchParams = useSearchParams();
|
||||
const idOrUuid = searchParams.get("uuid") || searchParams.get("id");
|
||||
|
||||
const { data, error, isLoading } = useSWR(
|
||||
idOrUuid ? `/api/certificates/${idOrUuid}` : null,
|
||||
(url) => axios.get(url).then((res) => res.data)
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader text={t("loading_details")} />;
|
||||
}
|
||||
|
||||
if (error || !data?.data) {
|
||||
return (
|
||||
<div className="p-10 text-center text-error-500">
|
||||
{t("details_fetch_failed")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const certificate = data.data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle={`${certificate.common_name} ${t("page_title")}`} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<CertificateDetails certificate={certificate} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/app/dashboard/certificates/view/page.tsx
Normal file
16
src/app/dashboard/certificates/view/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import CertificateDetailsClient from "./CertificateDetailsClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Certificate Details",
|
||||
description: "View detailed information about an SSL/TLS certificate.",
|
||||
};
|
||||
|
||||
export default function CertificateDetailPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-center py-20">Loading Certificate Details...</div>}>
|
||||
<CertificateDetailsClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
42
src/app/dashboard/layout.tsx
Normal file
42
src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import AppHeader from "@/layout/AppHeader";
|
||||
import AppSidebar from "@/layout/AppSidebar";
|
||||
import Backdrop from "@/layout/Backdrop";
|
||||
import React from "react";
|
||||
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
|
||||
useAuth({ middleware: 'auth' }); // Fetch user, but strict redirect is disabled in hook
|
||||
|
||||
// Dynamic class for main content margin based on sidebar state
|
||||
const mainContentMargin = isMobileOpen
|
||||
? "ml-0"
|
||||
: isExpanded || isHovered
|
||||
? "lg:ml-[290px]"
|
||||
: "lg:ml-[90px]";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen xl:flex font-sans">
|
||||
{/* Sidebar and Backdrop */}
|
||||
<AppSidebar />
|
||||
<Backdrop />
|
||||
{/* Main Content Area */}
|
||||
<div
|
||||
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<AppHeader />
|
||||
{/* Page Content */}
|
||||
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
352
src/app/dashboard/notifications/NotificationsClient.tsx
Normal file
352
src/app/dashboard/notifications/NotificationsClient.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import axios from "@/lib/axios";
|
||||
import { Bell, Check, Info, AlertTriangle, Search, Filter, Trash2, CheckCircle, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import { getUserAvatar } from "@/lib/utils";
|
||||
import echo from "@/lib/echo";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: string;
|
||||
data: {
|
||||
message: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
sender_name?: string;
|
||||
sender_avatar?: string;
|
||||
icon?: string;
|
||||
type?: string;
|
||||
};
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function NotificationsClient() {
|
||||
const router = useRouter();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [markingAll, setMarkingAll] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filter, setFilter] = useState("all"); // all, unread, read
|
||||
const [pagination, setPagination] = useState({
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
total: 0
|
||||
});
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
// Real-time updates
|
||||
useEffect(() => {
|
||||
if (user?.id && echo) {
|
||||
const channel = echo.private(`App.Models.User.${user.id}`);
|
||||
|
||||
channel.notification(() => {
|
||||
fetchNotifications();
|
||||
});
|
||||
|
||||
// Also listen for wildcard/raw events if needed (robustness)
|
||||
channel.listen('.Illuminate\\Notifications\\Events\\BroadcastNotificationCreated', () => {
|
||||
fetchNotifications();
|
||||
});
|
||||
|
||||
return () => {
|
||||
channel.stopListening('.Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
||||
// We don't leave the channel because NotificationDropdown might be using it too
|
||||
};
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.default_landing_page && user.default_landing_page !== '/dashboard') {
|
||||
router.push(user.default_landing_page);
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearch(searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [pagination.current_page, filter, debouncedSearch]);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get("/api/notifications", {
|
||||
params: {
|
||||
page: pagination.current_page,
|
||||
filter: filter !== 'all' ? filter : undefined,
|
||||
search: debouncedSearch || undefined
|
||||
}
|
||||
});
|
||||
setNotifications(response.data.data || []);
|
||||
setPagination({
|
||||
current_page: response.data.current_page,
|
||||
last_page: response.data.last_page,
|
||||
total: response.data.total
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch notifications:", error);
|
||||
} finally {
|
||||
setLoading(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));
|
||||
} catch (error) {
|
||||
console.error("Failed to mark notification as read:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
setMarkingAll(true);
|
||||
try {
|
||||
await axios.post('/api/notifications/mark-all-read');
|
||||
setNotifications(notifications.map(n => ({ ...n, read_at: new Date().toISOString() })));
|
||||
} catch (error) {
|
||||
console.error("Failed to mark all as read:", error);
|
||||
} finally {
|
||||
setMarkingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNotification = async (id: string) => {
|
||||
// Assuming a delete endpoint exists or will be added
|
||||
try {
|
||||
await axios.delete(`/api/notifications/${id}`);
|
||||
setNotifications(notifications.filter(n => n.id !== id));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete notification:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationClick = async (notification: Notification) => {
|
||||
if (!notification.read_at) {
|
||||
await markAsRead(notification.id);
|
||||
}
|
||||
|
||||
const url = notification.data.url;
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
};
|
||||
|
||||
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 "Just now";
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const getNotificationIcon = (notification: Notification) => {
|
||||
const iconName = notification.data.icon;
|
||||
const type = notification.type;
|
||||
|
||||
if (iconName === 'check-circle') return <Check size={20} className="text-green-500" />;
|
||||
if (iconName === 'trash-2' || iconName === 'alert-triangle') return <AlertTriangle size={20} className="text-orange-500" />;
|
||||
if (iconName === 'support-ticket') return <Bell size={20} className="text-brand-500" />;
|
||||
if (iconName === 'inbox') return <Bell size={20} className="text-blue-500" />;
|
||||
|
||||
// Fallback logic
|
||||
if (type.includes('Expiry') || type.includes('Urgent')) {
|
||||
return <AlertTriangle size={20} className="text-orange-500" />;
|
||||
}
|
||||
return <Info size={20} className="text-blue-500" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">Notifications History</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">View and manage your recent activity notifications.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
disabled={markingAll || notifications.every(n => n.read_at)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg h-10 hover:bg-gray-50 disabled:opacity-50 dark:bg-gray-900 dark:border-gray-800 dark:text-gray-300 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<CheckCircle size={18} />
|
||||
Mark all as read
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:bg-gray-900 dark:border-gray-800 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search notifications..."
|
||||
className="w-full pl-10 pr-4 py-2 text-sm bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus:border-brand-500 dark:bg-gray-800 dark:border-gray-700 dark:text-white transition-all"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${filter === 'all' ? 'bg-brand-500 text-white' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('unread')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${filter === 'unread' ? 'bg-brand-500 text-white' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
>
|
||||
Unread
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('read')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${filter === 'read' ? 'bg-brand-500 text-white' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-20 flex justify-center">
|
||||
<PageLoader text="Loading notifications..." />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-16 h-16 mb-4 bg-gray-50 rounded-full flex items-center justify-center dark:bg-gray-800 text-gray-300 dark:text-gray-600">
|
||||
<Bell size={32} />
|
||||
</div>
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white">No notifications found</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-sm">
|
||||
{debouncedSearch ? `No results for "${debouncedSearch}". Try a different search term.` : "You don't have any notifications yet."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{notifications.map((notification) => {
|
||||
const isTicket = notification.type.includes('Ticket');
|
||||
const hasSender = notification.data.sender_name || notification.data.sender_avatar;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`group flex gap-4 p-4 rounded-xl border transition-all cursor-pointer ${
|
||||
!notification.read_at
|
||||
? 'bg-brand-50/20 border-brand-100 dark:bg-brand-500/5 dark:border-brand-900/30'
|
||||
: 'bg-white border-transparent hover:border-gray-100 hover:bg-gray-50/50 dark:bg-transparent dark:hover:bg-white/5 dark:hover:border-gray-800'
|
||||
}`}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
>
|
||||
<div className="shrink-0 pt-0.5">
|
||||
{isTicket && hasSender ? (
|
||||
<div className="relative w-12 h-12 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-12 h-12 rounded-full ${!notification.read_at ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800'}`}>
|
||||
{getNotificationIcon(notification)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col">
|
||||
<h5 className={`text-base leading-tight text-gray-900 dark:text-white ${!notification.read_at ? 'font-bold' : 'font-semibold'}`}>
|
||||
{notification.data.title || 'System Update'}
|
||||
</h5>
|
||||
<p className="text-xs text-gray-400 font-medium mt-1">
|
||||
{notification.data.type?.split('\\').pop()?.replace('Notification', '') || 'System'} • {getRelativeTime(notification.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!notification.read_at && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markAsRead(notification.id);
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-brand-500 hover:bg-brand-50 dark:hover:bg-brand-500/10 rounded-lg transition-colors"
|
||||
title="Mark as read"
|
||||
>
|
||||
<CheckCircle size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteNotification(notification.id);
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2 leading-relaxed">
|
||||
{notification.data.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination.last_page > 1 && (
|
||||
<div className="flex items-center justify-between mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Showing page {pagination.current_page} of {pagination.last_page} ({pagination.total} notifications)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={pagination.current_page === 1}
|
||||
onClick={() => setPagination(prev => ({ ...prev, current_page: prev.current_page - 1 }))}
|
||||
className="p-2 text-gray-500 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 disabled:opacity-30 dark:bg-gray-900 dark:border-gray-800 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
disabled={pagination.current_page === pagination.last_page}
|
||||
onClick={() => setPagination(prev => ({ ...prev, current_page: prev.current_page + 1 }))}
|
||||
className="p-2 text-gray-500 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 disabled:opacity-30 dark:bg-gray-900 dark:border-gray-800 transition-colors"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/app/dashboard/notifications/page.tsx
Normal file
10
src/app/dashboard/notifications/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import NotificationsClient from "./NotificationsClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Notifications | TrustLab",
|
||||
description: "View and manage your notifications history.",
|
||||
};
|
||||
|
||||
export default function NotificationsPage() {
|
||||
return <NotificationsClient />;
|
||||
}
|
||||
13
src/app/dashboard/page.tsx
Normal file
13
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import DashboardClient from "./DashboardClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dashboard | TrustLab",
|
||||
description: "View your metrics and system status.",
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<DashboardClient />
|
||||
);
|
||||
}
|
||||
28
src/app/dashboard/profile/ProfileClient.tsx
Normal file
28
src/app/dashboard/profile/ProfileClient.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import UserMetaCard from "@/components/user-profile/UserMetaCard";
|
||||
import UserInfoCard from "@/components/user-profile/UserInfoCard";
|
||||
import UserAddressCard from "@/components/user-profile/UserAddressCard";
|
||||
import UserPasswordCard from "@/components/user-profile/UserPasswordCard";
|
||||
|
||||
export default function ProfileClient() {
|
||||
const t = useTranslations("Profile");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<h3 className="mb-5 text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-7">
|
||||
{t("profile_title")}
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
<UserMetaCard />
|
||||
<UserInfoCard />
|
||||
<UserAddressCard />
|
||||
<UserPasswordCard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/app/dashboard/profile/page.tsx
Normal file
12
src/app/dashboard/profile/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Metadata } from "next";
|
||||
import React from "react";
|
||||
import ProfileClient from "./ProfileClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "User Profile",
|
||||
description: "View and update your personal information.",
|
||||
};
|
||||
|
||||
export default function Profile() {
|
||||
return <ProfileClient />;
|
||||
}
|
||||
26
src/app/dashboard/services/ServicesClient.tsx
Normal file
26
src/app/dashboard/services/ServicesClient.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { Rocket } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function ServicesClient() {
|
||||
const t = useTranslations("Services");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center p-6">
|
||||
<div className="w-20 h-20 bg-brand-50 dark:bg-brand-500/10 rounded-full flex items-center justify-center mb-6">
|
||||
<Rocket className="w-10 h-10 text-brand-500" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{t("page_title")}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mb-8">
|
||||
{t("page_desc")}
|
||||
</p>
|
||||
<div className="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 rounded-lg text-sm font-medium">
|
||||
{t("coming_soon")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/app/dashboard/services/page.tsx
Normal file
12
src/app/dashboard/services/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Metadata } from "next";
|
||||
import React from "react";
|
||||
import ServicesClient from "./ServicesClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "My Services | TrustLab",
|
||||
description: "Manage your services.",
|
||||
};
|
||||
|
||||
export default function MyServicesPage() {
|
||||
return <ServicesClient />;
|
||||
}
|
||||
913
src/app/dashboard/settings/SettingsClient.tsx
Normal file
913
src/app/dashboard/settings/SettingsClient.tsx
Normal file
@@ -0,0 +1,913 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import axios from "@/lib/axios";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import Input from "@/components/form/input/InputField";
|
||||
import Label from "@/components/form/Label";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { useModal } from "@/hooks/useModal";
|
||||
import Badge from "@/components/ui/badge/Badge";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ShieldCheck,
|
||||
History,
|
||||
Link2,
|
||||
AlertTriangle,
|
||||
Mail,
|
||||
Smartphone,
|
||||
Globe,
|
||||
Monitor,
|
||||
Trash2,
|
||||
Lock,
|
||||
Bell,
|
||||
Palette,
|
||||
Send,
|
||||
Languages,
|
||||
Download,
|
||||
Key,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
DoorOpen,
|
||||
Sun,
|
||||
Moon,
|
||||
Laptop
|
||||
} from "lucide-react";
|
||||
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
import Switch from "@/components/form/switch/Switch";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useI18n } from "@/components/providers/I18nProvider";
|
||||
|
||||
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
|
||||
|
||||
export default function SettingsClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { data: user, mutate: mutateUser } = useSWR("/api/user", fetcher);
|
||||
const { data: loginHistory, isLoading: historyLoading } = useSWR("/api/profile/login-history", fetcher);
|
||||
const { data: sessions, isLoading: sessionsLoading } = useSWR("/api/profile/sessions", fetcher);
|
||||
const { data: apiKeys } = useSWR("/api/api-keys", fetcher);
|
||||
|
||||
const { addToast } = useToast();
|
||||
const { theme: currentTheme, setTheme } = useTheme();
|
||||
const { isOpen, openModal, closeModal } = useModal();
|
||||
const { setLocale } = useI18n();
|
||||
const t = useTranslations("Settings");
|
||||
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
current_password: "",
|
||||
password: "",
|
||||
password_confirmation: "",
|
||||
});
|
||||
|
||||
// 2FA State
|
||||
const [twoFactorMode, setTwoFactorMode] = useState<'enable' | 'disable' | 'recovery' | null>(null);
|
||||
const [qrCode, setQrCode] = useState<string | null>(null);
|
||||
const [setupSecret, setSetupSecret] = useState<string | null>(null);
|
||||
const [verificationCode, setVerificationCode] = useState("");
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [isProcessing2FA, setIsProcessing2FA] = useState(false);
|
||||
|
||||
const enable2FA = async () => {
|
||||
try {
|
||||
setIsProcessing2FA(true);
|
||||
const { data } = await axios.post('/api/auth/2fa/enable');
|
||||
setQrCode(data.qr_code);
|
||||
setSetupSecret(data.secret);
|
||||
setTwoFactorMode('enable');
|
||||
} catch (error) {
|
||||
addToast(t("toast_2fa_setup_failed"), "error");
|
||||
} finally {
|
||||
setIsProcessing2FA(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmEnable2FA = async () => {
|
||||
try {
|
||||
setIsProcessing2FA(true);
|
||||
const { data } = await axios.post('/api/auth/2fa/confirm', { code: verificationCode });
|
||||
addToast(t("toast_2fa_enabled"), "success");
|
||||
setRecoveryCodes(data.recovery_codes);
|
||||
setTwoFactorMode('recovery'); // Show recovery codes immediately
|
||||
mutateUser();
|
||||
} catch (error: any) {
|
||||
addToast(error.response?.data?.message || t("toast_2fa_setup_failed"), "error");
|
||||
} finally {
|
||||
setIsProcessing2FA(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disable2FA = async () => {
|
||||
try {
|
||||
setIsProcessing2FA(true);
|
||||
await axios.delete('/api/auth/2fa/disable', { data: { password: confirmPassword } });
|
||||
addToast(t("toast_2fa_disabled"), "success");
|
||||
setTwoFactorMode(null);
|
||||
setConfirmPassword("");
|
||||
mutateUser();
|
||||
} catch (error: any) {
|
||||
addToast(error.response?.data?.message || t("toast_2fa_disable_failed"), "error");
|
||||
} finally {
|
||||
setIsProcessing2FA(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showRecoveryCodes = async () => {
|
||||
try {
|
||||
const { data } = await axios.get('/api/auth/2fa/recovery-codes');
|
||||
setRecoveryCodes(data.recovery_codes);
|
||||
setTwoFactorMode('recovery');
|
||||
} catch (error) {
|
||||
addToast(t("toast_recovery_failed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const close2FAModal = () => {
|
||||
setTwoFactorMode(null);
|
||||
setQrCode(null);
|
||||
setVerificationCode("");
|
||||
setRecoveryCodes([]);
|
||||
setConfirmPassword("");
|
||||
};
|
||||
|
||||
// Handle Success/Error Alerts from URL (e.g. from OAuth Callback)
|
||||
React.useEffect(() => {
|
||||
const success = searchParams.get("success");
|
||||
const error = searchParams.get("error");
|
||||
|
||||
if (success) {
|
||||
if (success === 'account_connected') addToast(t("toast_connected"), "success");
|
||||
// Clear params
|
||||
router.replace("/dashboard/settings");
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error === 'already_connected') addToast(t("toast_already_connected"), "warning");
|
||||
if (error === 'connected_to_other_account') addToast(t("toast_connected_other"), "error");
|
||||
if (error === 'login_required_to_connect') addToast(t("toast_login_required"), "error");
|
||||
// Clear params
|
||||
router.replace("/dashboard/settings");
|
||||
}
|
||||
}, [searchParams, router, addToast, t]);
|
||||
|
||||
const connectAccount = async (provider: string) => {
|
||||
try {
|
||||
addToast(t("toast_connect_init"), "info");
|
||||
// Get secure link token to identify user during redirect
|
||||
const { data } = await axios.get('/api/auth/link-token');
|
||||
// Redirect to backend auth endpoint with context and token
|
||||
window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/api/auth/${provider}/redirect?context=connect&link_token=${data.token}`;
|
||||
} catch (error) {
|
||||
addToast(t("toast_connect_failed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const disconnectAccount = async (provider: string) => {
|
||||
// confirm disconnect?
|
||||
try {
|
||||
addToast(t("toast_disconnect_init"), "info");
|
||||
await axios.delete(`/api/auth/social/${provider}`);
|
||||
addToast(t("toast_disconnected"), "success");
|
||||
mutateUser(); // Refresh user state
|
||||
} catch (error: any) {
|
||||
addToast(error.response?.data?.message || t("toast_disconnect_failed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const savePreference = async (key: string, value: any) => {
|
||||
try {
|
||||
// Optimistic update could go here, but for simplicity we rely on SWR revalidation or just wait
|
||||
await axios.patch('/api/profile', {
|
||||
[key]: value
|
||||
});
|
||||
|
||||
if (key === 'theme') setTheme(value);
|
||||
if (key === 'language') setLocale(value);
|
||||
|
||||
mutateUser(); // Refresh user data to confirm sync
|
||||
addToast(t("toast_settings_updated"), "success");
|
||||
} catch (err) {
|
||||
addToast(t("toast_settings_failed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = (field: string, value: string) => {
|
||||
setPasswordData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const updatePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (passwordData.password !== passwordData.password_confirmation) {
|
||||
addToast(t("toast_password_mismatch"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingPassword(true);
|
||||
try {
|
||||
await axios.put("/api/profile/password", passwordData);
|
||||
addToast(t("toast_password_updated"), "success");
|
||||
setPasswordData({
|
||||
current_password: "",
|
||||
password: "",
|
||||
password_confirmation: "",
|
||||
});
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.message || t("toast_password_failed");
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
setIsSavingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeSession = async (id: string) => {
|
||||
try {
|
||||
await axios.delete(`/api/profile/sessions/${id}`);
|
||||
mutate("/api/profile/sessions");
|
||||
addToast(t("toast_session_revoked"), "success");
|
||||
} catch (err) {
|
||||
addToast(t("toast_revoke_failed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
setIsDeletingAccount(true);
|
||||
try {
|
||||
await axios.delete("/api/profile");
|
||||
addToast(t("toast_account_deleted"), "success");
|
||||
setTimeout(() => {
|
||||
window.location.href = "/signin";
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
addToast(t("toast_delete_failed"), "error");
|
||||
} finally {
|
||||
setIsDeletingAccount(false);
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
const getDeviceIcon = (deviceType: string) => {
|
||||
switch (deviceType?.toLowerCase()) {
|
||||
case 'ios':
|
||||
case 'android':
|
||||
return <Smartphone className="w-5 h-5 text-gray-500" />;
|
||||
case 'mac':
|
||||
case 'windows':
|
||||
case 'linux':
|
||||
return <Monitor className="w-5 h-5 text-gray-500" />;
|
||||
default:
|
||||
return <Globe className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Account Verification & Summary */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-blue-50 dark:bg-blue-500/10">
|
||||
<Mail className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||
{t("email_verification")}
|
||||
</h4>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
{user?.email_verified_at ? (
|
||||
<Badge color="success" size="sm" variant="light" startIcon={<ShieldCheck className="w-3.5 h-3.5" />}>
|
||||
{t("verified")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="warning" size="sm" variant="light">
|
||||
{t("not_verified")}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 font-medium">
|
||||
{user?.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-purple-50 dark:bg-purple-500/10">
|
||||
<Key className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||
{t("api_keys_summary")}
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("api_keys_desc", { count: apiKeys?.data?.filter((k: any) => k.is_active).length || 0 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security: Update Password */}
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<h4 className="mb-6 text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||
{t("security_password")}
|
||||
</h4>
|
||||
<form onSubmit={updatePassword} 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>
|
||||
<Input
|
||||
type="password"
|
||||
value={passwordData.current_password}
|
||||
onChange={(e) => handlePasswordChange("current_password", e.target.value)}
|
||||
placeholder={t("current_password")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("new_password")}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={passwordData.password}
|
||||
onChange={(e) => handlePasswordChange("password", e.target.value)}
|
||||
placeholder={t("new_password")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("confirm_new_password")}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={passwordData.password_confirmation}
|
||||
onChange={(e) => handlePasswordChange("password_confirmation", e.target.value)}
|
||||
placeholder={t("confirm_new_password")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={isSavingPassword}>
|
||||
{t("update_password")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Active Sessions Management */}
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<h4 className="mb-6 text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||
{t("active_sessions")}
|
||||
</h4>
|
||||
{sessionsLoading ? (
|
||||
<PageLoader text={t("loading_sessions")} className="py-10" />
|
||||
) : sessions?.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{sessions.map((session: any) => (
|
||||
<div key={session.id} className="flex items-center justify-between p-4 border border-gray-100 rounded-xl dark:border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 rounded-lg dark:bg-gray-800">
|
||||
<Monitor className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
{session.name || "Default Session"}
|
||||
</p>
|
||||
{session.is_current && (
|
||||
<Badge color="success" size="sm" variant="light">
|
||||
{t("session_current")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Last active: {new Date(session.last_active * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!session.is_current && (
|
||||
<button
|
||||
onClick={() => revokeSession(session.id)}
|
||||
className="p-2 text-red-500 transition-colors rounded-lg hover:bg-red-50 dark:hover:bg-red-500/10"
|
||||
title={t("logout_device")}
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-10 text-center text-gray-500 border-2 border-dashed border-gray-100 rounded-xl dark:border-gray-800">
|
||||
{t("no_sessions")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Login History (Last 30 Days) */}
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||
{t("login_activity")}
|
||||
</h4>
|
||||
<Badge color="light" size="sm" variant="light">
|
||||
{t("last_month")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{historyLoading ? (
|
||||
<PageLoader text="Loading activity..." className="py-10" />
|
||||
) : loginHistory?.length > 0 ? (
|
||||
<div className="overflow-x-auto border border-gray-100 rounded-xl dark:border-gray-800">
|
||||
<table className="min-w-[600px] w-full text-left">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-white/[0.02]">
|
||||
<th className="px-5 py-4 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400 w-[30%]">{t("browser_os")}</th>
|
||||
<th className="px-5 py-4 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400 w-[20%]">{t("ip_address")}</th>
|
||||
<th className="px-5 py-4 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400 w-[25%]">{t("location")}</th>
|
||||
<th className="px-5 py-4 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400 w-[25%] text-right">{t("time")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{loginHistory.map((item: any) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50/50 dark:hover:bg-white/[0.01]">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{getDeviceIcon(item.device_type)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">{item.browser}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{item.os}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">{item.ip_address}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.country_code && (
|
||||
<div className="flex-shrink-0 w-5 h-3.5 overflow-hidden rounded-[2px] shadow-sm border border-gray-100 dark:border-gray-800">
|
||||
<img
|
||||
src={`https://flagcdn.com/${item.country_code.toLowerCase()}.svg`}
|
||||
alt={item.country}
|
||||
className="block w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{item.city && item.country ? `${item.city}, ${item.country}` : "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right whitespace-nowrap">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{new Date(item.created_at).toLocaleString()}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-10 text-center text-gray-500 border-2 border-dashed border-gray-100 rounded-xl dark:border-gray-800">
|
||||
{t("no_activity")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Security & UI Placeholders */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* 2FA Section */}
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
||||
<Lock className="w-5 h-5" />
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">{t("2fa_title")}</h4>
|
||||
</div>
|
||||
{user?.two_factor_confirmed_at ? (
|
||||
<Badge color="success" size="sm" variant="light">Enabled</Badge>
|
||||
) : (
|
||||
<Badge color="warning" size="sm" variant="light">Disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("2fa_desc")}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
{user?.two_factor_confirmed_at ? (
|
||||
<div className="flex gap-3">
|
||||
<Button variant="danger" size="sm" className="w-full" onClick={() => setTwoFactorMode('disable')}>
|
||||
{t("disable_2fa")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={showRecoveryCodes}>
|
||||
{t("view_recovery_codes")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={enable2FA}>
|
||||
{t("setup_2fa")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications Placeholder */}
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
||||
<Bell className="w-5 h-5" />
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">{t("notifications")}</h4>
|
||||
</div>
|
||||
{/* Badge Removed */}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t("email_alerts")}</span>
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t("email_alerts_desc")}</span>
|
||||
</div>
|
||||
<Switch
|
||||
label=""
|
||||
defaultChecked={!!user?.settings_email_alerts}
|
||||
onChange={(checked) => savePreference('settings_email_alerts', checked)}
|
||||
disabled={!user}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t("cert_renewal")}</span>
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t("cert_renewal_desc")}</span>
|
||||
</div>
|
||||
<Switch
|
||||
label=""
|
||||
defaultChecked={!!user?.settings_certificate_renewal}
|
||||
onChange={(checked) => savePreference('settings_certificate_renewal', checked)}
|
||||
disabled={!user}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(user?.role === 'admin' || user?.role === 'owner') && (
|
||||
<div className="mt-5 pt-5 border-t border-gray-100 dark:border-gray-800">
|
||||
<Link href="/dashboard/admin/smtp-tester">
|
||||
<Button variant="outline" size="sm" className="w-full flex items-center justify-center gap-2">
|
||||
<Send className="w-4 h-4" /> {t("smtp_tester")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Appearance Settings */}
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
||||
<Palette className="w-5 h-5" />
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">{t("appearance")}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="mb-3 block text-sm font-medium">{t('theme')}</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ id: 'light', label: t('light'), icon: <Sun className="w-4 h-4" /> },
|
||||
{ id: 'dark', label: t('dark'), icon: <Moon className="w-4 h-4" /> },
|
||||
{ id: 'system', label: t('system'), icon: <Laptop className="w-4 h-4" /> },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => savePreference('theme', t.id as any)}
|
||||
className={`flex flex-col items-center justify-center p-3 border rounded-xl transition-all gap-2 ${
|
||||
user?.theme === t.id
|
||||
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-500/10 text-brand-600 dark:text-brand-400'
|
||||
: 'border-gray-100 dark:border-gray-800 text-gray-500 dark:text-gray-400 hover:border-brand-200'
|
||||
}`}
|
||||
>
|
||||
{t.icon}
|
||||
<span className="text-xs font-medium">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 block text-sm font-medium">{t('language')}</Label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={user?.language || 'en'}
|
||||
onChange={(e) => savePreference('language', e.target.value)}
|
||||
className="w-full bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 rounded-xl px-4 py-3 text-sm text-gray-700 dark:text-gray-300 focus:border-brand-500 outline-none appearance-none transition-all cursor-pointer"
|
||||
>
|
||||
<option value="en" className="bg-white dark:bg-gray-900">{t('english')}</option>
|
||||
<option value="id" className="bg-white dark:bg-gray-900">{t('indonesian')}</option>
|
||||
</select>
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
|
||||
<ChevronRight className="w-4 h-4 rotate-90" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export & Data Placeholder */}
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
||||
<Download className="w-5 h-5" />
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">{t("privacy_data")}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{t("privacy_desc")}</p>
|
||||
<Button variant="outline" size="sm" disabled className="w-full flex items-center justify-center gap-2">
|
||||
<Download className="w-4 h-4" /> {t("export_data")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Landing Page Selection */}
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6 lg:col-span-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
||||
<DoorOpen className="w-5 h-5" />
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">{t("landing_page")}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ id: '/dashboard', label: t('landing_page_dashboard'), icon: <Monitor className="w-4 h-4" /> },
|
||||
{ id: '/dashboard/support', label: t('landing_page_support'), icon: <Smartphone className="w-4 h-4" /> },
|
||||
{ id: '/dashboard/certificates', label: t('landing_page_certs'), icon: <ShieldCheck className="w-4 h-4" /> },
|
||||
{ id: '/dashboard/api-keys', label: t('landing_page_keys'), icon: <Key className="w-4 h-4" /> },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => savePreference('default_landing_page', option.id)}
|
||||
className={`flex items-center justify-between p-4 border rounded-xl transition-all ${
|
||||
user?.default_landing_page === option.id
|
||||
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-500/10 text-brand-600 dark:text-brand-400 font-bold'
|
||||
: 'border-gray-100 dark:border-gray-800 text-gray-500 dark:text-gray-400 hover:border-brand-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon}
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</div>
|
||||
{user?.default_landing_page === option.id && (
|
||||
<div className="w-2 h-2 rounded-full bg-brand-500" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Linked Accounts */}
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<h4 className="mb-6 text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||
{t("linked_accounts")}
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border border-gray-100 rounded-xl dark:border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 border border-gray-100 rounded-full dark:border-gray-800">
|
||||
<svg width="20" height="20" viewBox="0 0 48 48">
|
||||
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
|
||||
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
|
||||
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24s.92 7.54 2.56 10.78l7.97-6.19z"/>
|
||||
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
|
||||
<path fill="none" d="M0 0h48v48H0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">{t("google_account")}</p>
|
||||
<div className="mt-1">
|
||||
{user?.social_accounts?.some((a: any) => a.provider === 'google') ? (
|
||||
<Badge color="success" size="sm" variant="light">{t("connected")}</Badge>
|
||||
) : (
|
||||
<Badge color="light" size="sm" variant="light">{t("not_connected")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{user?.social_accounts?.some((a: any) => a.provider === 'google') ? (
|
||||
<Button variant="outline" size="sm" onClick={() => disconnectAccount('google')}>
|
||||
{t("disconnect")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => connectAccount('google')}>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 border border-gray-100 rounded-xl dark:border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 border border-gray-100 rounded-full dark:border-gray-800">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" className="text-gray-800 dark:text-white" fill="currentColor">
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">{t("github_account")}</p>
|
||||
<div className="mt-1">
|
||||
{user?.social_accounts?.some((a: any) => a.provider === 'github') ? (
|
||||
<Badge color="success" size="sm" variant="light">{t("connected")}</Badge>
|
||||
) : (
|
||||
<Badge color="light" size="sm" variant="light">{t("not_connected")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{user?.social_accounts?.some((a: any) => a.provider === 'github') ? (
|
||||
<Button variant="outline" size="sm" onClick={() => disconnectAccount('github')}>
|
||||
{t("disconnect")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => connectAccount('github')}>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="p-5 border border-red-200 bg-red-50 rounded-2xl dark:border-red-500/15 dark:bg-red-500/5 lg:p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-white rounded-full shadow-sm dark:bg-red-500/10">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||
{t("danger_zone")}
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("delete_account_desc")}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Button variant="danger" onClick={openModal}>
|
||||
{t("delete_account_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[450px] m-4">
|
||||
<div className="p-6 sm:p-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex items-center justify-center w-16 h-16 mb-4 bg-red-50 rounded-full dark:bg-red-500/10">
|
||||
<Trash2 className="w-8 h-8 text-red-600 dark:text-red-500" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-bold text-gray-800 dark:text-white/90">{t("delete_modal_title")}</h3>
|
||||
<p className="mb-8 text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("delete_modal_desc")}
|
||||
</p>
|
||||
<div className="flex flex-col w-full gap-3 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={closeModal} disabled={isDeletingAccount}>
|
||||
{t("delete_modal_cancel")}
|
||||
</Button>
|
||||
<Button variant="danger" className="w-full sm:w-auto" onClick={handleDeleteAccount} loading={isDeletingAccount}>
|
||||
{t("delete_modal_confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 2FA Modals */}
|
||||
<Modal isOpen={twoFactorMode !== null} onClose={close2FAModal} className="max-w-[450px] m-4">
|
||||
<div className="p-6 sm:p-8">
|
||||
{twoFactorMode === 'enable' && (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex items-center justify-center w-12 h-12 mb-4 bg-blue-50 rounded-full dark:bg-blue-500/10">
|
||||
<Smartphone className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-bold text-gray-800 dark:text-white/90">{t("setup_2fa_modal_title")}</h3>
|
||||
<p className="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("setup_2fa_modal_desc")}
|
||||
</p>
|
||||
|
||||
{!qrCode ? (
|
||||
<div className="py-8">
|
||||
<PageLoader text={t("loading_sessions")} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full space-y-6">
|
||||
<div className="p-4 bg-white border border-gray-100 rounded-xl dark:bg-white/5 dark:border-gray-800">
|
||||
<p className="mb-3 text-xs font-bold text-gray-400 uppercase tracking-wider">{t("2fa_step_1")}</p>
|
||||
<div className="flex justify-center mb-4 bg-white p-2 rounded-lg inline-block mx-auto">
|
||||
<div dangerouslySetInnerHTML={{ __html: qrCode }} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("2fa_step_1_desc")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 border border-gray-100 rounded-xl dark:bg-white/5 dark:border-gray-800 text-left">
|
||||
<p className="mb-2 text-xs font-bold text-gray-400 uppercase tracking-wider">{t("2fa_step_2")}</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("code_placeholder")}
|
||||
className="flex-1 px-4 py-2 bg-white dark:bg-black/20 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-all font-mono text-center tracking-widest text-lg"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value.replace(/[^0-9]/g, '').slice(0, 6))}
|
||||
maxLength={6}
|
||||
/>
|
||||
<Button
|
||||
onClick={confirmEnable2FA}
|
||||
loading={isProcessing2FA}
|
||||
disabled={verificationCode.length !== 6}
|
||||
>
|
||||
{t("verify_enable")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("2fa_step_2_desc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{twoFactorMode === 'recovery' && (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex items-center justify-center w-12 h-12 mb-4 bg-green-50 rounded-full dark:bg-green-500/10">
|
||||
<Lock className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-bold text-gray-800 dark:text-white/90">{t("recovery_codes_title")}</h3>
|
||||
<p className="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("recovery_codes_desc")}
|
||||
</p>
|
||||
|
||||
<div className="w-full bg-gray-50 dark:bg-black/20 border border-gray-200 dark:border-gray-800 rounded-xl p-4 mb-6">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm font-mono font-medium text-gray-800 dark:text-gray-200">
|
||||
{recoveryCodes.map((code, idx) => (
|
||||
<div key={idx} className="bg-white dark:bg-white/5 px-2 py-1 rounded border border-gray-100 dark:border-gray-700/50">
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-3">
|
||||
<Button variant="outline" className="flex-1" onClick={() => {
|
||||
navigator.clipboard.writeText(recoveryCodes.join("\n"));
|
||||
addToast("Codes copied to clipboard", "success");
|
||||
}}>
|
||||
{t("copy_codes")}
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={close2FAModal}>
|
||||
{t("done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{twoFactorMode === 'disable' && (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex items-center justify-center w-16 h-16 mb-4 bg-red-50 rounded-full dark:bg-red-500/10">
|
||||
<Lock className="w-8 h-8 text-red-600 dark:text-red-500" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-bold text-gray-800 dark:text-white/90">{t("disable_2fa")}?</h3>
|
||||
<p className="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("disable_2fa_warning")}
|
||||
</p>
|
||||
|
||||
<div className="w-full mb-6 text-left">
|
||||
<Label className="mb-2 block">{t("current_password")}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={t("current_password")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-full gap-3 sm:flex-row sm:justify-center">
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={close2FAModal}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="danger" className="w-full sm:w-auto" onClick={disable2FA} loading={isProcessing2FA} disabled={!confirmPassword}>
|
||||
{t("confirm_disable_2fa")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/app/dashboard/settings/page.tsx
Normal file
27
src/app/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import SettingsClient from "./SettingsClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Account Settings",
|
||||
description: "Manage your account security, activity, and preferences.",
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<h3 className="mb-5 text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-7">
|
||||
Account Settings
|
||||
</h3>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-brand-500 border-t-transparent"></div>
|
||||
</div>
|
||||
}>
|
||||
<SettingsClient />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
338
src/app/dashboard/support/TicketDetailsClient.tsx
Normal file
338
src/app/dashboard/support/TicketDetailsClient.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import useSWR from "swr";
|
||||
import axios from "@/lib/axios";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { Send, ArrowLeft, Clock, CheckCircle, AlertCircle, User, MessageSquare, XCircle, Paperclip, FileText, X } from "lucide-react";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import Link from "next/link";
|
||||
import { getUserAvatar, parseApiError, getAttachmentUrl } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
|
||||
import ConfirmationModal from "@/components/common/ConfirmationModal";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const statusColors: any = {
|
||||
open: "bg-blue-100 text-blue-800 dark:bg-blue-500/10 dark:text-blue-400 border border-blue-200 dark:border-blue-800/20",
|
||||
answered: "bg-green-100 text-green-800 dark:bg-green-500/10 dark:text-green-400 border border-green-200 dark:border-green-800/20",
|
||||
closed: "bg-gray-100 text-gray-800 dark:bg-gray-500/10 dark:text-gray-400 border border-gray-200 dark:border-gray-800/20",
|
||||
};
|
||||
|
||||
export default function TicketDetailsClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const ticketId = searchParams.get("id");
|
||||
const { user } = useAuth();
|
||||
const { addToast } = useToast();
|
||||
const t = useTranslations("Tickets"); // Initialize translations
|
||||
const [replyMessage, setReplyMessage] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isCloseModalOpen, setIsCloseModalOpen] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: ticket, error, isLoading, mutate } = useSWR(
|
||||
ticketId ? `/api/support/tickets/${ticketId}` : null,
|
||||
(url) => axios.get(url).then((res) => res.data)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [ticket?.replies]);
|
||||
|
||||
const handleReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!replyMessage.trim() && selectedFiles.length === 0) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
const formData = new FormData();
|
||||
formData.append("message", replyMessage);
|
||||
selectedFiles.forEach((file) => formData.append("attachments[]", file));
|
||||
|
||||
try {
|
||||
await axios.post(`/api/support/tickets/${ticketId}/reply`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
setReplyMessage("");
|
||||
setSelectedFiles([]);
|
||||
mutate();
|
||||
addToast(t("toast_reply_sent"), "success");
|
||||
} catch (err: any) {
|
||||
addToast(parseApiError(err, t("toast_reply_failed")), "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseTicket = async () => {
|
||||
try {
|
||||
await axios.patch(`/api/support/tickets/${ticketId}/close`);
|
||||
addToast(t("toast_closed"), "success");
|
||||
setIsCloseModalOpen(false);
|
||||
mutate();
|
||||
} catch (err: any) {
|
||||
addToast(parseApiError(err, t("toast_close_failed")), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const filesArray = Array.from(e.target.files);
|
||||
if (selectedFiles.length + filesArray.length > 5) {
|
||||
addToast(t("toast_max_files"), "error");
|
||||
return;
|
||||
}
|
||||
setSelectedFiles((prev) => [...prev, ...filesArray]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => t(`status_${status.toLowerCase()}`);
|
||||
const getCategoryLabel = (cat: string) => {
|
||||
if (cat === "Feature Request") return t("cat_feature");
|
||||
return t(`cat_${cat.toLowerCase()}`);
|
||||
};
|
||||
// Wait, I should implement getCategoryLabel same as TicketListClient or helper, but inline is fine.
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader text={t("loading_conversation")} />;
|
||||
}
|
||||
|
||||
if (error?.response?.status === 404 || !ticket) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100 dark:border-gray-800 shadow-theme-xl max-w-2xl mx-auto">
|
||||
<div className="w-20 h-20 bg-gray-50 dark:bg-white/5 rounded-full flex items-center justify-center mb-6">
|
||||
<AlertCircle size={40} className="text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">{t("not_found_title")}</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center max-w-md px-6 mb-8">
|
||||
{t("not_found_desc")}
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/support"
|
||||
className="flex items-center gap-2 px-6 py-3 bg-brand-500 text-white rounded-xl hover:bg-brand-600 transition-all shadow-theme-md font-bold"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
{t("back_to_tickets")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<Link
|
||||
href="/dashboard/support"
|
||||
className="flex items-center gap-2 text-gray-500 hover:text-brand-500 transition-colors font-medium group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-gray-100 dark:bg-white/5 flex items-center justify-center group-hover:bg-brand-500 group-hover:text-white transition-all">
|
||||
<ArrowLeft size={18} />
|
||||
</div>
|
||||
{t("back_to_tickets")}
|
||||
</Link>
|
||||
|
||||
{ticket.status !== 'closed' && (
|
||||
<button
|
||||
onClick={() => setIsCloseModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-red-200 dark:border-red-900/30 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/5 rounded-lg hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<XCircle size={18} />
|
||||
{t("close_ticket")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ComponentCard className="p-0 overflow-hidden border-none shadow-theme-xl">
|
||||
{/* Ticket Header */}
|
||||
<div className="p-6 bg-gray-50 dark:bg-white/2 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-bold text-gray-400 uppercase tracking-wider">#{ticket.ticket_number}</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase ${statusColors[ticket.status]}`}>
|
||||
{getStatusLabel(ticket.status)}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{ticket.subject}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-gray-500">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase">{t("ticket_header_category")}</span>
|
||||
<span className="font-bold text-gray-700 dark:text-gray-300">
|
||||
{getCategoryLabel(ticket.category)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase">{t("ticket_header_priority")}</span>
|
||||
<span className="font-bold text-gray-700 dark:text-gray-300 capitalize">{t(`priority_${ticket.priority.toLowerCase()}`)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase">{t("ticket_header_created")}</span>
|
||||
<span className="font-bold text-gray-700 dark:text-gray-300">{new Date(ticket.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversation Area */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-[500px] overflow-y-auto p-6 space-y-6 flex flex-col custom-scrollbar dark:bg-gray-900/50"
|
||||
>
|
||||
{ticket.replies?.map((reply: any) => {
|
||||
const isMe = reply.user_id === user?.id;
|
||||
return (
|
||||
<div
|
||||
key={reply.id}
|
||||
className={`flex gap-3 max-w-[85%] ${isMe ? 'self-end flex-row-reverse' : 'self-start'}`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Image
|
||||
width={40}
|
||||
height={40}
|
||||
src={getUserAvatar(reply.user)}
|
||||
alt={reply.user?.name || "User"}
|
||||
className="rounded-full shadow-sm"
|
||||
unoptimized={true}
|
||||
/>
|
||||
</div>
|
||||
<div className={`space-y-1 ${isMe ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`flex items-center gap-2 px-2 ${isMe ? 'flex-row-reverse' : 'flex-row'}`}>
|
||||
<span className="text-[11px] font-bold text-gray-500 dark:text-gray-400 uppercase">
|
||||
{reply.user?.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{new Date(reply.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`p-4 rounded-2xl text-sm leading-relaxed shadow-theme-xs ${
|
||||
isMe
|
||||
? 'bg-brand-500 text-white rounded-tr-none'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-tl-none border border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
{reply.message}
|
||||
|
||||
{/* Attachments Display */}
|
||||
{reply.attachments && reply.attachments.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{reply.attachments.map((att: any) => (
|
||||
<a
|
||||
key={att.id}
|
||||
href={getAttachmentUrl(att.file_path)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center gap-2 p-2 rounded-lg text-xs transition-colors ${
|
||||
isMe
|
||||
? 'bg-white/10 hover:bg-white/20 text-white'
|
||||
: 'bg-white dark:bg-white/5 hover:bg-gray-50 dark:hover:bg-white/10 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span className="truncate max-w-[150px]">{att.file_name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reply Toolbox */}
|
||||
{ticket.status !== 'closed' ? (
|
||||
<div className="p-6 bg-white dark:bg-gray-dark border-t border-gray-100 dark:border-gray-800">
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{selectedFiles.map((file, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 px-3 py-1 bg-gray-100 dark:bg-white/5 rounded-full text-xs text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-800">
|
||||
<span className="truncate max-w-[100px]">{file.name}</span>
|
||||
<button onClick={() => removeFile(idx)} className="text-gray-400 hover:text-red-500">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleReply} className="relative">
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder={t("reply_placeholder")}
|
||||
className="w-full p-4 pr-16 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-2xl focus:ring-2 focus:ring-brand-500 resize-none transition-all placeholder:text-gray-400 dark:placeholder:text-white/20 text-gray-900 dark:text-white"
|
||||
value={replyMessage}
|
||||
onChange={(e) => setReplyMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
handleReply(e as any);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute right-3 bottom-3 flex gap-2">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".jpg,.jpeg,.png,.pdf,.doc,.docx,.zip,.txt"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-3 bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-xl hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Attach Files"
|
||||
>
|
||||
<Paperclip size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || (!replyMessage.trim() && selectedFiles.length === 0)}
|
||||
className="p-3 bg-brand-500 text-white rounded-xl hover:bg-brand-600 transition-all shadow-theme-md disabled:bg-gray-200 dark:disabled:bg-gray-800 disabled:text-gray-400"
|
||||
>
|
||||
<Send size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="mt-2 text-[10px] text-gray-400 text-center italic">
|
||||
{t("reply_hint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-10 text-center bg-gray-100 dark:bg-white/5 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="w-16 h-16 bg-white dark:bg-gray-900 rounded-full flex items-center justify-center mx-auto mb-4 shadow-theme-lg border border-gray-200 dark:border-gray-700">
|
||||
<CheckCircle size={32} className="text-green-500 dark:text-green-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{t("ticket_closed_title")}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{t("ticket_closed_desc")}</p>
|
||||
</div>
|
||||
)}
|
||||
</ComponentCard>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={isCloseModalOpen}
|
||||
onClose={() => setIsCloseModalOpen(false)}
|
||||
onConfirm={handleCloseTicket}
|
||||
title={t("close_modal_title")}
|
||||
message={t("close_modal_message")}
|
||||
confirmLabel={t("close_modal_confirm")}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
329
src/app/dashboard/support/TicketListClient.tsx
Normal file
329
src/app/dashboard/support/TicketListClient.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import axios from "@/lib/axios";
|
||||
import { Plus, Search, MessageSquare, Clock, CheckCircle, AlertCircle, Paperclip, X } from "lucide-react";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import Link from "next/link";
|
||||
import { parseApiError } from "@/lib/utils";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
|
||||
|
||||
const statusColors: any = {
|
||||
open: "bg-blue-100 text-blue-800 dark:bg-blue-500/10 dark:text-blue-400",
|
||||
answered: "bg-green-100 text-green-800 dark:bg-green-500/10 dark:text-green-400",
|
||||
closed: "bg-gray-100 text-gray-800 dark:bg-gray-500/10 dark:text-gray-400",
|
||||
};
|
||||
|
||||
const priorityColors: any = {
|
||||
low: "bg-gray-100 text-gray-800 dark:bg-gray-500/10 dark:text-gray-400",
|
||||
medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-500/10 dark:text-yellow-400",
|
||||
high: "bg-red-100 text-red-800 dark:bg-red-500/10 dark:text-red-400",
|
||||
};
|
||||
|
||||
export default function TicketListClient() {
|
||||
const { addToast } = useToast();
|
||||
const t = useTranslations("Tickets");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const { data, mutate, isLoading } = useSWR("/api/support/tickets", fetcher);
|
||||
|
||||
const [newTicket, setNewTicket] = useState({
|
||||
subject: "",
|
||||
category: "Technical",
|
||||
priority: "medium",
|
||||
message: "",
|
||||
});
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const newFiles = Array.from(e.target.files);
|
||||
|
||||
// Calculate remaining slots
|
||||
const remainingSlots = 5 - selectedFiles.length;
|
||||
|
||||
if (newFiles.length > remainingSlots) {
|
||||
addToast(t("toast_max_files") + " (Max 5 totals)", "error"); // Partially localized, refined later if needed
|
||||
return;
|
||||
}
|
||||
|
||||
const validFiles = newFiles.slice(0, remainingSlots).filter(file => file.size <= 10 * 1024 * 1024);
|
||||
if (validFiles.length !== newFiles.length) {
|
||||
addToast("Some files skipped (max 10MB)", "warning");
|
||||
}
|
||||
setSelectedFiles(prev => [...prev, ...validFiles]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleCreateTicket = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("subject", newTicket.subject);
|
||||
formData.append("category", newTicket.category);
|
||||
formData.append("priority", newTicket.priority);
|
||||
formData.append("message", newTicket.message);
|
||||
selectedFiles.forEach(file => {
|
||||
formData.append("attachments[]", file);
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post("/api/support/tickets", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
addToast(t("toast_created"), "success");
|
||||
setIsModalOpen(false);
|
||||
setNewTicket({ subject: "", category: "Technical", priority: "medium", message: "" });
|
||||
setSelectedFiles([]);
|
||||
mutate();
|
||||
} catch (error: any) {
|
||||
console.error("Ticket creation error:", error.response?.data || error.message);
|
||||
addToast(parseApiError(error, t("toast_create_failed")), "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tickets = data?.data || [];
|
||||
const filteredTickets = tickets.filter((t: any) =>
|
||||
t.subject.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
t.ticket_number.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
return t(`status_${status.toLowerCase()}`);
|
||||
};
|
||||
|
||||
const getCategoryLabel = (cat: string) => {
|
||||
// "Technical" -> "cat_technical"
|
||||
// "Feature Request" -> "cat_feature"
|
||||
// "Other" -> "cat_other"
|
||||
if (cat === "Feature Request") return t("cat_feature");
|
||||
return t(`cat_${cat.toLowerCase()}`);
|
||||
};
|
||||
|
||||
const getPriorityLabel = (prio: string) => {
|
||||
return t(`priority_${prio.toLowerCase()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageBreadcrumb pageTitle={t("page_title")} />
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<Search size={18} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("search_placeholder")}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 dark:bg-gray-900 dark:border-gray-800 text-gray-900 dark:text-white"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors w-full sm:w-auto justify-center"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{t("create_new")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{isLoading ? (
|
||||
<PageLoader text={t("loading_tickets")} className="py-20" />
|
||||
) : filteredTickets.length > 0 ? (
|
||||
filteredTickets.map((ticket: any) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
href={`/dashboard/support/view?id=${ticket.id}`}
|
||||
className="block group"
|
||||
>
|
||||
<ComponentCard className="transition-all hover:border-brand-500 border-transparent border-2">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-gray-50 dark:bg-white/5 rounded-full flex items-center justify-center text-brand-500 group-hover:bg-brand-500 group-hover:text-white transition-colors">
|
||||
<MessageSquare size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-bold text-gray-400 uppercase tracking-wider">
|
||||
#{ticket.ticket_number}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase ${statusColors[ticket.status]}`}>
|
||||
{getStatusLabel(ticket.status)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white group-hover:text-brand-500 transition-colors text-lg">
|
||||
{ticket.subject}
|
||||
</h3>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{new Date(ticket.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 capitalize">
|
||||
<AlertCircle size={14} />
|
||||
{getCategoryLabel(ticket.category)}
|
||||
</span>
|
||||
<span className={`flex items-center gap-1 capitalize px-2 py-0.5 rounded-full text-[10px] font-bold ${priorityColors[ticket.priority]}`}>
|
||||
{getPriorityLabel(ticket.priority)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<span className="px-4 py-2 bg-gray-50 dark:bg-white/5 rounded-lg text-brand-500 font-bold group-hover:bg-brand-500 group-hover:text-white transition-colors">
|
||||
{t("view_details")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-20 bg-gray-50 dark:bg-white/5 rounded-2xl border-2 border-dashed border-gray-200 dark:border-gray-800">
|
||||
<div className="w-20 h-20 bg-white dark:bg-gray-900 rounded-full flex items-center justify-center mx-auto mb-6 shadow-theme-lg">
|
||||
<MessageSquare size={40} className="text-gray-200" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">{t("no_tickets_title")}</h3>
|
||||
<p className="text-gray-500 max-w-sm mx-auto mb-8">
|
||||
{t("no_tickets_desc")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="px-6 py-3 bg-brand-500 text-white rounded-xl font-bold hover:bg-brand-600 transition-all shadow-theme-md"
|
||||
>
|
||||
{t("create_first")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Ticket Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-[100000] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-dark w-full max-w-lg rounded-2xl shadow-theme-xl overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||
<div className="flex justify-between items-center p-6 border-b border-gray-100 dark:border-gray-800">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">{t("create_modal_title")}</h2>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-gray-600">
|
||||
<Plus size={24} className="rotate-45" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreateTicket} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1">{t("subject_label")}</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder={t("subject_placeholder")}
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl focus:ring-2 focus:ring-brand-500 text-gray-900 dark:text-white"
|
||||
value={newTicket.subject}
|
||||
onChange={(e) => setNewTicket({ ...newTicket, subject: e.target.value })}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1">{t("category_label")}</label>
|
||||
<select
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl focus:ring-2 focus:ring-brand-500 text-gray-900 dark:text-white"
|
||||
value={newTicket.category}
|
||||
onChange={(e) => setNewTicket({ ...newTicket, category: e.target.value })}
|
||||
>
|
||||
<option value="Technical">{t("cat_technical")}</option>
|
||||
<option value="Billing">{t("cat_billing")}</option>
|
||||
<option value="Account">{t("cat_account")}</option>
|
||||
<option value="Feature Request">{t("cat_feature")}</option>
|
||||
<option value="Other">{t("cat_other")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1">{t("priority_label")}</label>
|
||||
<select
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl focus:ring-2 focus:ring-brand-500 text-gray-900 dark:text-white"
|
||||
value={newTicket.priority}
|
||||
onChange={(e) => setNewTicket({ ...newTicket, priority: e.target.value })}
|
||||
>
|
||||
<option value="low">{t("priority_low")}</option>
|
||||
<option value="medium">{t("priority_medium")}</option>
|
||||
<option value="high">{t("priority_high")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1">{t("message_label")}</label>
|
||||
<textarea
|
||||
required
|
||||
rows={4}
|
||||
placeholder={t("message_placeholder")}
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl focus:ring-2 focus:ring-brand-500 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
value={newTicket.message}
|
||||
onChange={(e) => setNewTicket({ ...newTicket, message: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1">{t("attachments_label")}</label>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedFiles.map((file, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 px-3 py-1 bg-gray-100 dark:bg-white/5 rounded-full text-xs text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-800">
|
||||
<span className="truncate max-w-[100px]">{file.name}</span>
|
||||
<button type="button" onClick={() => removeFile(idx)} className="text-gray-400 hover:text-red-500">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-900 border border-dashed border-gray-300 dark:border-gray-700 rounded-xl cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
|
||||
<Paperclip size={18} className="text-gray-400" />
|
||||
<span className="text-sm text-gray-500">{t("attach_hint")}</span>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept=".jpg,.jpeg,.png,.pdf,.doc,.docx,.zip,.txt"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="flex-1 px-4 py-3 bg-gray-100 dark:bg-white/5 text-gray-700 dark:text-gray-300 rounded-xl font-bold hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
{t("cancel_button")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 px-4 py-3 bg-brand-500 text-white rounded-xl font-bold hover:bg-brand-600 transition-all shadow-theme-md disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? t("creating_button") : t("submit_button")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/dashboard/support/page.tsx
Normal file
11
src/app/dashboard/support/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from "next";
|
||||
import TicketListClient from "./TicketListClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Support Tickets",
|
||||
description: "Manage your support tickets and get assistance.",
|
||||
};
|
||||
|
||||
export default function SupportPage() {
|
||||
return <TicketListClient />;
|
||||
}
|
||||
16
src/app/dashboard/support/view/page.tsx
Normal file
16
src/app/dashboard/support/view/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import TicketDetailsClient from "../TicketDetailsClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Ticket Details",
|
||||
description: "View and reply to your support ticket.",
|
||||
};
|
||||
|
||||
export default function TicketDetailsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-center py-20">Loading Ticket...</div>}>
|
||||
<TicketDetailsClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
193
src/app/dashboard/users/view/UserProfileClient.tsx
Normal file
193
src/app/dashboard/users/view/UserProfileClient.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import useSWR from "swr";
|
||||
import axios from "@/lib/axios";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Mail, Calendar, Shield, User, MapPin, Phone } from "lucide-react";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import Image from "next/image";
|
||||
import { getUserAvatar } from "@/lib/utils";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export default function UserProfileClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const id = searchParams.get("id");
|
||||
|
||||
const { data, error, isLoading } = useSWR(
|
||||
id ? `/api/admin/users/${id}` : null,
|
||||
(url) => axios.get(url).then((res) => res.data)
|
||||
);
|
||||
|
||||
const user = data;
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader text="Loading user profile..." />;
|
||||
}
|
||||
|
||||
if (error || !user) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">User Not Found</h3>
|
||||
<p className="text-gray-500 mt-2">The user you are looking for does not exist or you do not have permission to view this profile.</p>
|
||||
<Link
|
||||
href="/dashboard/admin/users"
|
||||
className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Users
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="p-2.5 text-gray-500 hover:text-brand-500 transition-colors bg-white dark:bg-white/5 border border-gray-200 dark:border-gray-800 rounded-xl shadow-theme-xs"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<div className="flex-1 sm:hidden">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">User Profile</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 -mb-6">
|
||||
<PageBreadcrumb pageTitle="User Profile Preview" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Profile Header Card */}
|
||||
<div className="md:col-span-3">
|
||||
<ComponentCard className="relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-32 bg-gradient-to-r from-brand-500/10 to-blue-500/10 dark:from-brand-500/20 dark:to-blue-500/20 z-0"></div>
|
||||
<div className="relative z-10 flex flex-col sm:flex-row items-center sm:items-end gap-6 pt-16 px-4 pb-4">
|
||||
<div className="relative">
|
||||
<div className="w-32 h-32 rounded-full border-4 border-white dark:border-gray-900 shadow-theme-lg overflow-hidden bg-white dark:bg-gray-800">
|
||||
<Image
|
||||
src={getUserAvatar(user)}
|
||||
alt={`${user.first_name} ${user.last_name}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<span className={`absolute bottom-2 right-2 w-5 h-5 rounded-full border-2 border-white dark:border-gray-900 ${user.email_verified_at ? 'bg-green-500' : 'bg-gray-400'}`} title={user.email_verified_at ? 'Verified' : 'Unverified'}></span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-center sm:text-left mb-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-1">
|
||||
{user.first_name} {user.last_name}
|
||||
</h1>
|
||||
<div className="flex flex-wrap justify-center sm:justify-start gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{(user.role === 'admin' || user.role === 'owner') ? <Shield size={14} className="text-brand-500" /> : <User size={14} />}
|
||||
<span className="capitalize">{user.role}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<MapPin size={14} />
|
||||
{user.city_state || user.country ? `${user.city_state || ''}${user.country ? ', ' + user.country : ''}` : 'Location unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={`mailto:${user.email}`}
|
||||
className="px-4 py-2 bg-brand-500 text-white text-sm font-bold rounded-xl hover:bg-brand-600 transition-colors shadow-theme-xs flex items-center gap-2"
|
||||
>
|
||||
<Mail size={16} /> Contact
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="md:col-span-1 space-y-6">
|
||||
<ComponentCard title="Contact Information">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-50 dark:bg-white/5 flex items-center justify-center flex-shrink-0 text-gray-400">
|
||||
<Mail size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-0.5">Email Address</p>
|
||||
<a href={`mailto:${user.email}`} className="text-sm font-medium text-brand-500 hover:underline break-all">{user.email}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-50 dark:bg-white/5 flex items-center justify-center flex-shrink-0 text-gray-400">
|
||||
<Phone size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-0.5">Phone Details</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{user.phone || 'Not provided'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-50 dark:bg-white/5 flex items-center justify-center flex-shrink-0 text-gray-400">
|
||||
<Calendar size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-0.5">Member Since</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{new Date(user.created_at).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
{/* Bio / Details */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<ComponentCard title="About User">
|
||||
{user.bio ? (
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{user.bio}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-gray-400 italic">No biography provided.</p>
|
||||
)}
|
||||
</ComponentCard>
|
||||
|
||||
<ComponentCard title="Additional Details">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">Job Title</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{user.job_title || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">Company</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{user.company || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">Postal Code</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{user.postal_code || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">Tax ID</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{user.tax_id || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/app/dashboard/users/view/page.tsx
Normal file
10
src/app/dashboard/users/view/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import UserProfileClient from "./UserProfileClient";
|
||||
|
||||
export default function UserProfilePage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center min-h-[400px]"><div className="w-10 h-10 border-4 border-brand-500 border-t-transparent rounded-full animate-spin"></div></div>}>
|
||||
<UserProfileClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
339
src/app/globals.css
Normal file
339
src/app/globals.css
Normal file
@@ -0,0 +1,339 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
|
||||
--breakpoint-*: initial;
|
||||
--breakpoint-2xsm: 375px;
|
||||
--breakpoint-xsm: 425px;
|
||||
--breakpoint-3xl: 2000px;
|
||||
--breakpoint-sm: 640px;
|
||||
--breakpoint-md: 768px;
|
||||
--breakpoint-lg: 1024px;
|
||||
--breakpoint-xl: 1280px;
|
||||
--breakpoint-2xl: 1536px;
|
||||
|
||||
--text-title-2xl: 72px;
|
||||
--text-title-2xl--line-height: 90px;
|
||||
--text-title-xl: 60px;
|
||||
--text-title-xl--line-height: 72px;
|
||||
--text-title-lg: 48px;
|
||||
--text-title-lg--line-height: 60px;
|
||||
--text-title-md: 36px;
|
||||
--text-title-md--line-height: 44px;
|
||||
--text-title-sm: 30px;
|
||||
--text-title-sm--line-height: 38px;
|
||||
--text-theme-xl: 20px;
|
||||
--text-theme-xl--line-height: 30px;
|
||||
--text-theme-sm: 14px;
|
||||
--text-theme-sm--line-height: 20px;
|
||||
--text-theme-xs: 12px;
|
||||
--text-theme-xs--line-height: 18px;
|
||||
|
||||
--color-current: currentColor;
|
||||
--color-transparent: transparent;
|
||||
--color-white: #ffffff;
|
||||
--color-black: #101828;
|
||||
|
||||
--color-brand-25: #f2f7ff;
|
||||
--color-brand-50: #ecf3ff;
|
||||
--color-brand-100: #dde9ff;
|
||||
--color-brand-200: #c2d6ff;
|
||||
--color-brand-300: #9cb9ff;
|
||||
--color-brand-400: #7592ff;
|
||||
--color-brand-500: #465fff;
|
||||
--color-brand-600: #3641f5;
|
||||
--color-brand-700: #2a31d8;
|
||||
--color-brand-800: #252dae;
|
||||
--color-brand-900: #262e89;
|
||||
--color-brand-950: #161950;
|
||||
|
||||
--color-blue-light-25: #f5fbff;
|
||||
--color-blue-light-50: #f0f9ff;
|
||||
--color-blue-light-100: #e0f2fe;
|
||||
--color-blue-light-200: #b9e6fe;
|
||||
--color-blue-light-300: #7cd4fd;
|
||||
--color-blue-light-400: #36bffa;
|
||||
--color-blue-light-500: #0ba5ec;
|
||||
--color-blue-light-600: #0086c9;
|
||||
--color-blue-light-700: #026aa2;
|
||||
--color-blue-light-800: #065986;
|
||||
--color-blue-light-900: #0b4a6f;
|
||||
--color-blue-light-950: #062c41;
|
||||
|
||||
--color-gray-25: #fcfcfd;
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f2f4f7;
|
||||
--color-gray-200: #e4e7ec;
|
||||
--color-gray-300: #d0d5dd;
|
||||
--color-gray-400: #98a2b3;
|
||||
--color-gray-500: #667085;
|
||||
--color-gray-600: #475467;
|
||||
--color-gray-700: #344054;
|
||||
--color-gray-800: #1d2939;
|
||||
--color-gray-900: #101828;
|
||||
--color-gray-950: #0c111d;
|
||||
--color-gray-dark: #1a2231;
|
||||
|
||||
--color-orange-25: #fffaf5;
|
||||
--color-orange-50: #fff6ed;
|
||||
--color-orange-100: #ffead5;
|
||||
--color-orange-200: #fddcab;
|
||||
--color-orange-300: #feb273;
|
||||
--color-orange-400: #fd853a;
|
||||
--color-orange-500: #fb6514;
|
||||
--color-orange-600: #ec4a0a;
|
||||
--color-orange-700: #c4320a;
|
||||
--color-orange-800: #9c2a10;
|
||||
--color-orange-900: #7e2410;
|
||||
--color-orange-950: #511c10;
|
||||
|
||||
--color-success-25: #f6fef9;
|
||||
--color-success-50: #ecfdf3;
|
||||
--color-success-100: #d1fadf;
|
||||
--color-success-200: #a6f4c5;
|
||||
--color-success-300: #6ce9a6;
|
||||
--color-success-400: #32d583;
|
||||
--color-success-500: #12b76a;
|
||||
--color-success-600: #039855;
|
||||
--color-success-700: #027a48;
|
||||
--color-success-800: #05603a;
|
||||
--color-success-900: #054f31;
|
||||
--color-success-950: #053321;
|
||||
|
||||
--color-error-25: #fffbfa;
|
||||
--color-error-50: #fef3f2;
|
||||
--color-error-100: #fee4e2;
|
||||
--color-error-200: #fecdca;
|
||||
--color-error-300: #fda29b;
|
||||
--color-error-400: #f97066;
|
||||
--color-error-500: #f04438;
|
||||
--color-error-600: #d92d20;
|
||||
--color-error-700: #b42318;
|
||||
--color-error-800: #912018;
|
||||
--color-error-900: #7a271a;
|
||||
--color-error-950: #55160c;
|
||||
|
||||
--color-warning-25: #fffcf5;
|
||||
--color-warning-50: #fffaeb;
|
||||
--color-warning-100: #fef0c7;
|
||||
--color-warning-200: #fedf89;
|
||||
--color-warning-300: #fec84b;
|
||||
--color-warning-400: #fdb022;
|
||||
--color-warning-500: #f79009;
|
||||
--color-warning-600: #dc6803;
|
||||
--color-warning-700: #b54708;
|
||||
--color-warning-800: #93370d;
|
||||
--color-warning-900: #7a2e0e;
|
||||
--color-warning-950: #4e1d09;
|
||||
|
||||
--color-theme-pink-500: #ee46bc;
|
||||
|
||||
--color-theme-purple-500: #7a5af8;
|
||||
|
||||
--shadow-theme-md:
|
||||
0px 4px 8px -2px rgba(16, 24, 40, 0.1),
|
||||
0px 2px 4px -2px rgba(16, 24, 40, 0.06);
|
||||
--shadow-theme-lg:
|
||||
0px 12px 16px -4px rgba(16, 24, 40, 0.08),
|
||||
0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
--shadow-theme-sm:
|
||||
0px 1px 3px 0px rgba(16, 24, 40, 0.1),
|
||||
0px 1px 2px 0px rgba(16, 24, 40, 0.06);
|
||||
--shadow-theme-xs: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
|
||||
--shadow-theme-xl:
|
||||
0px 20px 24px -4px rgba(16, 24, 40, 0.08),
|
||||
0px 8px 8px -4px rgba(16, 24, 40, 0.03);
|
||||
--shadow-datepicker: -5px 0 0 #262d3c, 5px 0 0 #262d3c;
|
||||
--shadow-focus-ring: 0px 0px 0px 4px rgba(70, 95, 255, 0.12);
|
||||
--shadow-slider-navigation:
|
||||
0px 1px 2px 0px rgba(16, 24, 40, 0.1), 0px 1px 3px 0px rgba(16, 24, 40, 0.1);
|
||||
--shadow-tooltip:
|
||||
0px 4px 6px -2px rgba(16, 24, 40, 0.05),
|
||||
-8px 0px 20px 8px rgba(16, 24, 40, 0.05);
|
||||
|
||||
--drop-shadow-4xl:
|
||||
0 35px 35px rgba(0, 0, 0, 0.25), 0 45px 65px rgba(0, 0, 0, 0.15);
|
||||
|
||||
--z-index-1: 1;
|
||||
--z-index-9: 9;
|
||||
--z-index-99: 99;
|
||||
--z-index-999: 999;
|
||||
--z-index-9999: 9999;
|
||||
--z-index-99999: 99999;
|
||||
--z-index-999999: 999999;
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
body {
|
||||
@apply relative z-1 bg-gray-50;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
}
|
||||
|
||||
@utility menu-item {
|
||||
@apply relative flex items-center w-full gap-3 px-3 py-2 font-medium rounded-lg text-theme-sm;
|
||||
}
|
||||
|
||||
@utility menu-item-active {
|
||||
@apply bg-brand-50 text-brand-500 dark:bg-brand-500/[0.12] dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-item-inactive {
|
||||
@apply text-gray-700 hover:bg-gray-100 group-hover:text-gray-700 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-gray-300;
|
||||
}
|
||||
|
||||
@utility menu-item-icon {
|
||||
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400;
|
||||
}
|
||||
|
||||
@utility menu-item-icon-active {
|
||||
@apply text-brand-500 dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-item-icon-inactive {
|
||||
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-300;
|
||||
}
|
||||
|
||||
@utility menu-item-arrow {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
@utility menu-item-arrow-active {
|
||||
@apply rotate-180 text-brand-500 dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-item-arrow-inactive {
|
||||
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-300;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-item {
|
||||
@apply relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-theme-sm font-medium;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-item-active {
|
||||
@apply bg-brand-50 text-brand-500 dark:bg-brand-500/[0.12] dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-item-inactive {
|
||||
@apply text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-badge {
|
||||
@apply block rounded-full px-2.5 py-0.5 text-xs font-medium uppercase text-brand-500 dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-badge-active {
|
||||
@apply bg-brand-100 dark:bg-brand-500/20;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-badge-inactive {
|
||||
@apply bg-brand-50 group-hover:bg-brand-100 dark:bg-brand-500/15 dark:group-hover:bg-brand-500/20;
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
/* Chrome, Safari and Opera */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
@utility custom-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
@apply size-1.5;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-200 rounded-full dark:bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #344054;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@layer utilities {
|
||||
/* For Remove Date Icon */
|
||||
input[type="date"]::-webkit-inner-spin-button,
|
||||
input[type="time"]::-webkit-inner-spin-button,
|
||||
input[type="date"]::-webkit-calendar-picker-indicator,
|
||||
input[type="time"]::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* third-party libraries CSS */
|
||||
/* ApexCharts, Flatpickr, FullCalendar, and VectorMap styles removed for cleanup */
|
||||
|
||||
.form-check-input:checked ~ span {
|
||||
@apply border-[6px] border-brand-500 bg-brand-500 dark:border-brand-500;
|
||||
}
|
||||
|
||||
.taskCheckbox:checked ~ .box span {
|
||||
@apply opacity-100 bg-brand-500;
|
||||
}
|
||||
.taskCheckbox:checked ~ p {
|
||||
@apply text-gray-400 line-through;
|
||||
}
|
||||
.taskCheckbox:checked ~ .box {
|
||||
@apply border-brand-500 bg-brand-500 dark:border-brand-500;
|
||||
}
|
||||
|
||||
.task {
|
||||
transition: all 0.2s ease; /* Smooth transition for visual effects */
|
||||
}
|
||||
|
||||
.task {
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 3px 0 rgba(16, 24, 40, 0.1),
|
||||
0 1px 2px 0 rgba(16, 24, 40, 0.06);
|
||||
opacity: 0.8;
|
||||
cursor: grabbing; /* Changes the cursor to indicate dragging */
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
40
src/app/layout.tsx
Normal file
40
src/app/layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
import './globals.css';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import { SidebarProvider } from '@/context/SidebarContext';
|
||||
import { ThemeProvider } from '@/context/ThemeContext';
|
||||
import { ToastProvider } from '@/context/ToastContext';
|
||||
import { ToastContainer } from '@/components/ui/toast/Toast';
|
||||
import { I18nProvider } from '@/components/providers/I18nProvider';
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: '%s | TrustLab - PKI & Certificate Management',
|
||||
default: 'TrustLab - PKI & Certificate Management',
|
||||
},
|
||||
description: 'Advanced Certificate Authority and PKI Management System',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased dark:bg-gray-900`} suppressHydrationWarning>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<ToastProvider>
|
||||
<SidebarProvider>{children}</SidebarProvider>
|
||||
<ToastContainer />
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
52
src/app/not-found.tsx
Normal file
52
src/app/not-found.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import GridShape from "@/components/common/GridShape";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function NotFound() {
|
||||
const t = useTranslations("Error");
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center min-h-screen p-6 overflow-hidden z-1">
|
||||
<GridShape />
|
||||
<div className="mx-auto w-full max-w-[242px] text-center sm:max-w-[472px]">
|
||||
<h1 className="mb-8 font-bold text-gray-800 text-title-md dark:text-white/90 xl:text-title-2xl">
|
||||
{t('title_404')}
|
||||
</h1>
|
||||
|
||||
<Image
|
||||
src="/images/error/404.svg"
|
||||
alt="404"
|
||||
className="dark:hidden"
|
||||
width={472}
|
||||
height={152}
|
||||
/>
|
||||
<Image
|
||||
src="/images/error/404-dark.svg"
|
||||
alt="404"
|
||||
className="hidden dark:block"
|
||||
width={472}
|
||||
height={152}
|
||||
/>
|
||||
|
||||
<p className="mt-10 mb-6 text-base text-gray-700 dark:text-gray-400 sm:text-lg">
|
||||
{t('desc_404')}
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-5 py-3.5 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"
|
||||
>
|
||||
{t('back_to_home')}
|
||||
</Link>
|
||||
</div>
|
||||
{/* <!-- Footer --> */}
|
||||
<p className="absolute text-sm text-center text-gray-500 -translate-x-1/2 bottom-6 left-1/2 dark:text-gray-400">
|
||||
© {new Date().getFullYear()} - TrustLab
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
src/components/ApiUsageDocs.tsx
Normal file
213
src/components/ApiUsageDocs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/components/Layouts/Public/Footer.tsx
Normal file
57
src/components/Layouts/Public/Footer.tsx
Normal 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">
|
||||
© 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>
|
||||
);
|
||||
}
|
||||
225
src/components/Layouts/Public/Navbar.tsx
Normal file
225
src/components/Layouts/Public/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/components/Layouts/PublicLayout.tsx
Normal file
23
src/components/Layouts/PublicLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/components/admin/RootCaTable.tsx
Normal file
141
src/components/admin/RootCaTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
src/components/api-keys/ApiKeyManagement.tsx
Normal file
264
src/components/api-keys/ApiKeyManagement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
src/components/certificates/CertificateDetails.tsx
Normal file
157
src/components/certificates/CertificateDetails.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
src/components/certificates/CertificateTable.tsx
Normal file
308
src/components/certificates/CertificateTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
253
src/components/certificates/CreateCertificateModal.tsx
Normal file
253
src/components/certificates/CreateCertificateModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/components/common/ChartTab.tsx
Normal file
45
src/components/common/ChartTab.tsx
Normal 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;
|
||||
17
src/components/common/CommonGridShape.tsx
Normal file
17
src/components/common/CommonGridShape.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
src/components/common/ComponentCard.tsx
Normal file
49
src/components/common/ComponentCard.tsx
Normal 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;
|
||||
130
src/components/common/ConfirmationModal.tsx
Normal file
130
src/components/common/ConfirmationModal.tsx
Normal 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;
|
||||
25
src/components/common/GridShape.tsx
Normal file
25
src/components/common/GridShape.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
137
src/components/common/ImageCropper.tsx
Normal file
137
src/components/common/ImageCropper.tsx
Normal 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;
|
||||
52
src/components/common/PageBreadCrumb.tsx
Normal file
52
src/components/common/PageBreadCrumb.tsx
Normal 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;
|
||||
11
src/components/common/Preloader.tsx
Normal file
11
src/components/common/Preloader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/components/common/ThemeToggle.tsx
Normal file
45
src/components/common/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/common/ThemeToggleButton.tsx
Normal file
42
src/components/common/ThemeToggleButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
42
src/components/common/ThemeTogglerTwo.tsx
Normal file
42
src/components/common/ThemeTogglerTwo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/ecommerce/EcommerceMetrics.tsx
Normal file
56
src/components/ecommerce/EcommerceMetrics.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
211
src/components/ecommerce/RecentOrders.tsx
Normal file
211
src/components/ecommerce/RecentOrders.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/example/ModalExample/DefaultModal.tsx
Normal file
53
src/components/example/ModalExample/DefaultModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
src/components/example/ModalExample/FormInModal.tsx
Normal file
71
src/components/example/ModalExample/FormInModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/example/ModalExample/FullScreenModal.tsx
Normal file
66
src/components/example/ModalExample/FullScreenModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
282
src/components/example/ModalExample/ModalBasedAlerts.tsx
Normal file
282
src/components/example/ModalExample/ModalBasedAlerts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
23
src/components/form/Form.tsx
Normal file
23
src/components/form/Form.tsx
Normal 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;
|
||||
27
src/components/form/Label.tsx
Normal file
27
src/components/form/Label.tsx
Normal 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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user