mirror of
https://github.com/dyzulk/trustlab.git
synced 2026-01-26 21:41:52 +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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user