Commit 580535d8 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/responsive-bottom-nav-and-ui-polish' into 'develop'

feat(ui): implement responsive bottom nav and polish interface metrics & localization

See merge request !9
parents 67f87917 8a7095fc
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Trung Tâm Xuất Dữ Liệu",
description:
"Quản lý danh sách các tệp dữ liệu đã xuất (CSV, JSON, XLSX, Markdown, ZIP) và tải file trực tiếp về máy tính.",
};
export default function ExportsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
This diff is collapsed.
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Quản Lý Lập Lịch Tự Động",
description:
"Giao diện thiết lập chu kỳ cào dữ liệu định kỳ, hỗ trợ bộ chọn trực quan theo chu kỳ hoặc biểu thức Cron và theo dõi lịch sử thực thi.",
};
export default function SchedulesLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
This diff is collapsed.
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Cài Đặt Nhà Phát Triển & Tích Hợp",
description:
"Quản lý khóa API Keys, cấu hình Webhook nhận sự kiện realtime và tích hợp ứng dụng bên ngoài với Data Crawler.",
};
export default function DeveloperSettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
This diff is collapsed.
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Hồ Sơ Cá Nhân & Cài Đặt",
description:
"Quản lý thông tin tài khoản cá nhân, ảnh đại diện, bảo mật mật khẩu, phiên đăng nhập và tùy chọn vô hiệu hóa tài khoản.",
};
export default function ProfileSettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
This diff is collapsed.
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Quản Lý Mẫu Bóc Tách",
description:
"Giao diện quản lý các bộ quy tắc bóc tách dữ liệu có cấu trúc (CSS Selector, XPath, Regex, JSON Schema) và gắn nhanh vào tác vụ cào.",
};
export default function TemplatesLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
This diff is collapsed.
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
import React from "react"; import React from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Navbar } from "@/components/common/navbar"; import { Navbar } from "@/components/common/navbar";
import { BottomNav } from "@/components/common/bottom-nav";
export const STANDALONE_PATHS = [ export const STANDALONE_PATHS = [
"/login", "/login",
...@@ -34,11 +35,12 @@ export function AppShell({ children }: AppShellProps) { ...@@ -34,11 +35,12 @@ export function AppShell({ children }: AppShellProps) {
<main className="flex-1 container mx-auto max-w-7xl px-4 py-8 sm:px-6"> <main className="flex-1 container mx-auto max-w-7xl px-4 py-8 sm:px-6">
{children} {children}
</main> </main>
<footer className="border-t border-border/60 py-6 text-center text-xs text-muted-foreground"> <footer className="border-t border-border/60 py-6 pb-24 md:pb-24 min-[1025px]:pb-6 text-center text-xs text-muted-foreground">
<p> <p>
Data Crawler © {new Date().getFullYear()} - Code by @hnihTyoB Data Crawler © {new Date().getFullYear()} - Code by @hnihTyoB
</p> </p>
</footer> </footer>
<BottomNav />
</> </>
); );
} }
"use client";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Activity, Calendar, Download, FileCode2, Globe } from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
export function BottomNav() {
const { t } = useLanguage();
const pathname = usePathname();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const navItems = [
{
href: "/",
label: t.nav.dashboard,
icon: Activity,
isActive: pathname === "/",
},
{
href: "/crawl-jobs",
label: t.nav.tasks,
icon: Globe,
isActive: pathname.startsWith("/crawl-jobs"),
},
{
href: "/schedules",
label: t.nav.schedules,
icon: Calendar,
isActive: pathname.startsWith("/schedules"),
},
{
href: "/templates",
label: t.nav.templates,
icon: FileCode2,
isActive: pathname.startsWith("/templates"),
},
{
href: "/exports",
label: t.nav.exports,
icon: Download,
isActive: pathname.startsWith("/exports"),
},
];
if (!mounted) {
return null;
}
return (
<nav
suppressHydrationWarning
aria-label="Bottom Navigation Bar"
className="min-[1025px]:hidden fixed bottom-2 left-0 right-0 z-40 border-t border-border/80 bg-card/90 backdrop-blur-xl shadow-[0_-4px_24px_rgba(0,0,0,0.06)] dark:shadow-[0_-4px_24px_rgba(0,0,0,0.35)] transition-colors duration-200 pb-[env(safe-area-inset-bottom,0px)]"
>
<div className="grid grid-cols-5 h-16 md:h-18 max-w-2xl mx-auto px-2 items-center">
{navItems.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
title={item.label}
aria-label={item.label}
className={`group relative flex flex-col items-center justify-center rounded-2xl py-1.5 px-1 transition-all duration-200 active:scale-95 ${
item.isActive
? "text-emerald-600 dark:text-emerald-400 font-semibold bg-emerald-500/10 dark:bg-emerald-500/20 shadow-xs"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40 font-medium"
}`}
>
<div className="relative flex items-center justify-center">
<Icon
className={`transition-all duration-200 ${
item.isActive
? "h-5 w-5 sm:h-5.5 sm:w-5.5 md:h-5 md:w-5 stroke-[2.25]"
: "h-5 w-5 sm:h-5.5 sm:w-5.5 md:h-5 md:w-5 stroke-[1.75] group-hover:scale-110"
}`}
/>
{/* Chấm tròn biểu thị trạng thái đang hoạt động trên màn hình điện thoại */}
{item.isActive && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-emerald-600 dark:bg-emerald-400 md:hidden" />
)}
</div>
{/* Tên tab: Ẩn trên điện thoại (< md), hiển thị bên dưới icon trên iPad (md đến 1024px) */}
<span className="hidden md:block text-[11px] tracking-tight truncate max-w-full text-center mt-1">
{item.label}
</span>
</Link>
);
})}
</div>
</nav>
);
}
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import React from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Activity, Bot, Database, Globe } from "lucide-react"; import { Activity, Bot, Calendar, Download, FileCode2, Globe } from "lucide-react";
import { ThemeToggle } from "./theme-toggle"; import { ThemeToggle } from "./theme-toggle";
import { LanguageToggle } from "./language-toggle"; import { LanguageToggle } from "./language-toggle";
import { UserMenu } from "./user-menu"; import { UserMenu } from "./user-menu";
...@@ -15,6 +15,9 @@ export function Navbar() { ...@@ -15,6 +15,9 @@ export function Navbar() {
const isDashboard = pathname === "/"; const isDashboard = pathname === "/";
const isCrawlJobs = pathname.startsWith("/crawl-jobs"); const isCrawlJobs = pathname.startsWith("/crawl-jobs");
const isSchedules = pathname.startsWith("/schedules");
const isTemplates = pathname.startsWith("/templates");
const isExports = pathname.startsWith("/exports");
return ( return (
<header className="sticky top-0 z-40 w-full border-b border-border/70 bg-card/80 backdrop-blur-md transition-colors duration-200"> <header className="sticky top-0 z-40 w-full border-b border-border/70 bg-card/80 backdrop-blur-md transition-colors duration-200">
...@@ -28,7 +31,7 @@ export function Navbar() { ...@@ -28,7 +31,7 @@ export function Navbar() {
</Link> </Link>
{/* Center Nav Links */} {/* Center Nav Links */}
<nav className="hidden md:flex items-center gap-6 text-sm font-medium text-muted-foreground"> <nav className="hidden min-[1025px]:flex items-center gap-5 text-sm font-medium text-muted-foreground">
<Link <Link
href="/" href="/"
className={`flex items-center gap-1.5 transition-colors ${ className={`flex items-center gap-1.5 transition-colors ${
...@@ -52,11 +55,37 @@ export function Navbar() { ...@@ -52,11 +55,37 @@ export function Navbar() {
{t.nav.tasks} {t.nav.tasks}
</Link> </Link>
<Link <Link
href="#storage" href="/schedules"
className="flex items-center gap-1.5 hover:text-foreground transition-colors" className={`flex items-center gap-1.5 transition-colors ${
isSchedules
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "hover:text-foreground"
}`}
>
<Calendar className="h-4 w-4" />
{t.nav.schedules}
</Link>
<Link
href="/templates"
className={`flex items-center gap-1.5 transition-colors ${
isTemplates
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "hover:text-foreground"
}`}
>
<FileCode2 className="h-4 w-4" />
{t.nav.templates}
</Link>
<Link
href="/exports"
className={`flex items-center gap-1.5 transition-colors ${
isExports
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "hover:text-foreground"
}`}
> >
<Database className="h-4 w-4" /> <Download className="h-4 w-4" />
{t.nav.storage} {t.nav.exports}
</Link> </Link>
</nav> </nav>
......
...@@ -5,17 +5,15 @@ import Link from "next/link"; ...@@ -5,17 +5,15 @@ import Link from "next/link";
import { import {
User, User,
LogOut, LogOut,
Shield,
LayoutDashboard,
ChevronDown, ChevronDown,
Code2,
} from "lucide-react"; } from "lucide-react";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
export function UserMenu() { export function UserMenu() {
const { user, isAuthenticated, isLoading, role, isAdmin, logout } = useAuth(); const { user, isAuthenticated, isLoading, role, logout } = useAuth();
const { t } = useLanguage(); const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
...@@ -137,25 +135,22 @@ export function UserMenu() { ...@@ -137,25 +135,22 @@ export function UserMenu() {
{/* Actions List */} {/* Actions List */}
<div className="space-y-1"> <div className="space-y-1">
<Link <Link
href="/" href="/settings/profile"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
> >
<LayoutDashboard className="h-3.5 w-3.5 text-emerald-500" /> <User className="h-3.5 w-3.5 text-emerald-500" />
<span>{t.nav.dashboard}</span> <span>{t.auth.userMenu.profile}</span>
</Link> </Link>
{isAdmin && ( <Link
<div className="flex items-center justify-between rounded-xl px-3 py-2 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-500/5 border border-emerald-500/10"> href="/settings/developer"
<div className="flex items-center gap-2.5"> onClick={() => setIsOpen(false)}
<Shield className="h-3.5 w-3.5 text-emerald-500" /> className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
<span>{t.auth.userMenu.admin}</span> >
</div> <Code2 className="h-3.5 w-3.5 text-emerald-500" />
<Badge variant="outline" className="text-[9px] border-emerald-500/30"> <span>{t.auth.userMenu.developer}</span>
ADMIN </Link>
</Badge>
</div>
)}
</div> </div>
{/* Divider */} {/* Divider */}
......
...@@ -141,16 +141,16 @@ export function ServiceHealthBar() { ...@@ -141,16 +141,16 @@ export function ServiceHealthBar() {
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/25" ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/25"
: "bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/25" : "bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/25"
)} )}
title={dbCheck?.error || "PostgreSQL connection normal"} title={dbCheck?.error || (isDbUp ? t.health.postgresReady : t.health.postgresDown)}
> >
<Database className="h-3.5 w-3.5 text-emerald-500 shrink-0" /> <Database className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span className="font-semibold">PostgreSQL</span> <span className="font-semibold">PostgreSQL</span>
{isDbUp ? ( {isDbUp ? (
<span className="text-[11px] opacity-80 whitespace-nowrap"> <span className="text-[11px] opacity-80 whitespace-nowrap">
{dbCheck?.latencyMs !== undefined ? `• ${dbCheck.latencyMs}ms` : "• Online"} {dbCheck?.latencyMs !== undefined ? `• ${dbCheck.latencyMs}ms` : `• ${t.health.statusOnline}`}
</span> </span>
) : ( ) : (
<span className="text-[11px] text-red-400 font-bold whitespace-nowrap">Offline</span> <span className="text-[11px] text-red-400 font-bold whitespace-nowrap">{t.health.statusOffline}</span>
)} )}
</div> </div>
...@@ -164,7 +164,7 @@ export function ServiceHealthBar() { ...@@ -164,7 +164,7 @@ export function ServiceHealthBar() {
? "bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/25" ? "bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/25"
: "bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/25" : "bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/25"
)} )}
title={redisCheck?.error || "BullMQ Queue active"} title={redisCheck?.error || (isRedisUp ? t.health.redisReady : isDegraded ? t.health.redisDegraded : t.health.redisDown)}
> >
<Layers className="h-3.5 w-3.5 text-teal-500 shrink-0" /> <Layers className="h-3.5 w-3.5 text-teal-500 shrink-0" />
<span className="font-semibold">Redis/BullMQ</span> <span className="font-semibold">Redis/BullMQ</span>
...@@ -172,8 +172,10 @@ export function ServiceHealthBar() { ...@@ -172,8 +172,10 @@ export function ServiceHealthBar() {
{redisCheck?.latencyMs !== undefined {redisCheck?.latencyMs !== undefined
? `• ${redisCheck.latencyMs}ms` ? `• ${redisCheck.latencyMs}ms`
: isRedisUp : isRedisUp
? "• Active" ? `• ${t.health.statusActive}`
: "• Degraded"} : isDegraded
? `• ${t.health.statusDegraded}`
: `• ${t.health.statusDown}`}
</span> </span>
{(activeJobs > 0 || waitingJobs > 0) && ( {(activeJobs > 0 || waitingJobs > 0) && (
<span className="hidden md:inline-flex items-center gap-1 rounded bg-teal-500/20 px-1.5 py-0.2 text-[10px] font-bold text-teal-600 dark:text-teal-300"> <span className="hidden md:inline-flex items-center gap-1 rounded bg-teal-500/20 px-1.5 py-0.2 text-[10px] font-bold text-teal-600 dark:text-teal-300">
......
This diff is collapsed.
"use client";
import React, { useState, useEffect } from "react";
import {
X,
Webhook,
Sparkles,
Key,
Radio,
Check,
RefreshCw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import {
useCreateWebhookConfig,
useUpdateWebhookConfig,
} from "@/hooks/use-developer";
import { WebhookConfig } from "@/types/developer";
interface CreateWebhookModalProps {
isOpen: boolean;
onClose: () => void;
initialData?: WebhookConfig | null;
}
const AVAILABLE_EVENTS = [
{ value: "crawl.job.completed", labelVi: "Tác vụ cào hoàn tất thành công", labelEn: "Crawl Job Completed" },
{ value: "crawl.job.failed", labelVi: "Tác vụ cào gặp sự cố lỗi", labelEn: "Crawl Job Failed" },
{ value: "crawl.job.running", labelVi: "Tác vụ bắt đầu thu thập dữ liệu", labelEn: "Crawl Job Started" },
{ value: "export.completed", labelVi: "Tệp xuất dữ liệu sẵn sàng", labelEn: "Export File Ready" },
{ value: "export.failed", labelVi: "Tạo tệp xuất thất bại", labelEn: "Export File Failed" },
];
export function CreateWebhookModal({
isOpen,
onClose,
initialData,
}: CreateWebhookModalProps) {
const { t, locale } = useLanguage();
const createMutation = useCreateWebhookConfig();
const updateMutation = useUpdateWebhookConfig();
const isEditing = !!initialData;
const isSubmitting = createMutation.isPending || updateMutation.isPending;
const [url, setUrl] = useState("");
const [secret, setSecret] = useState("");
const [selectedEvents, setSelectedEvents] = useState<string[]>([
"crawl.job.completed",
"crawl.job.failed",
]);
const generateRandomSecret = () => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_!#*";
let res = "";
for (let i = 0; i < 32; i++) {
res += chars.charAt(Math.floor(Math.random() * chars.length));
}
setSecret(res);
};
useEffect(() => {
if (initialData) {
setUrl(initialData.url);
setSelectedEvents(initialData.events || []);
setSecret(""); // Keep hidden for security on edit unless modified
} else {
setUrl("");
setSelectedEvents(["crawl.job.completed", "crawl.job.failed"]);
generateRandomSecret();
}
}, [initialData, isOpen]);
if (!isOpen) return null;
const toggleEvent = (eventVal: string) => {
setSelectedEvents((prev) =>
prev.includes(eventVal)
? prev.filter((e) => e !== eventVal)
: [...prev, eventVal]
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!url.trim() || selectedEvents.length === 0) return;
if (isEditing && initialData) {
await updateMutation.mutateAsync({
id: initialData.id,
dto: {
url: url.trim(),
events: selectedEvents,
secret: secret.trim() ? secret.trim() : undefined,
},
});
} else {
if (!secret.trim()) {
generateRandomSecret();
}
await createMutation.mutateAsync({
url: url.trim(),
secret: secret.trim(),
events: selectedEvents,
});
}
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-xl flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Webhook className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
{isEditing ? t.developer.webhooks.modal.editTitle : t.developer.webhooks.modal.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.developer.webhooks.modal.desc}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="py-4 space-y-4">
{/* Endpoint URL */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.urlLabel}
</Label>
<Input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t.developer.webhooks.modal.urlPlaceholder}
className="rounded-2xl text-xs"
required
/>
</div>
{/* HMAC Secret */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.secretLabel}
</Label>
<button
type="button"
onClick={generateRandomSecret}
className="inline-flex items-center gap-1 text-[11px] text-emerald-600 dark:text-emerald-400 hover:underline cursor-pointer"
>
<RefreshCw className="h-3 w-3" />
<span>{t.developer.webhooks.modal.generateSecret}</span>
</button>
</div>
<Input
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder={isEditing ? "Giữ nguyên nếu không muốn đổi khóa..." : t.developer.webhooks.modal.secretPlaceholder}
className="rounded-2xl font-mono text-xs"
minLength={isEditing ? 0 : 16}
/>
</div>
{/* Events Multi-select */}
<div className="space-y-2 pt-2 border-t border-border/60">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.eventsLabel}
</Label>
<div className="space-y-2">
{AVAILABLE_EVENTS.map((ev) => {
const isChecked = selectedEvents.includes(ev.value);
return (
<label
key={ev.value}
onClick={() => toggleEvent(ev.value)}
className={`flex items-center justify-between p-2.5 rounded-2xl border transition-colors cursor-pointer select-none ${
isChecked
? "border-emerald-500/40 bg-emerald-500/10 text-foreground"
: "border-border/70 bg-card/60 hover:bg-muted/40 text-muted-foreground"
}`}
>
<div>
<p className="text-xs font-semibold text-foreground font-mono">{ev.value}</p>
<p className="text-[11px] text-muted-foreground">{ev[locale === "vi" ? "labelVi" : "labelEn"]}</p>
</div>
<div
className={`h-4 w-4 rounded-md border flex items-center justify-center transition-colors ${
isChecked
? "bg-emerald-600 border-emerald-600 text-white"
: "border-border"
}`}
>
{isChecked && <Check className="h-3 w-3" />}
</div>
</label>
);
})}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-3 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
>
{t.developer.webhooks.modal.cancel}
</Button>
<Button
type="submit"
disabled={isSubmitting || !url.trim() || selectedEvents.length === 0}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{isSubmitting
? t.developer.webhooks.modal.submitting
: t.developer.webhooks.modal.submit}
</Button>
</div>
</form>
</div>
</div>
);
}
"use client";
import React, { useState } from "react";
import {
X,
Download,
FileSpreadsheet,
FileText,
FileJson,
FileCode,
Archive,
Check,
Globe,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import { useCrawlJobs } from "@/hooks/use-crawl-jobs";
import { useCreateCrawlExport } from "@/hooks/use-crawl-exports";
import { ExportType } from "@/types/export";
interface CreateExportModalProps {
isOpen: boolean;
onClose: () => void;
defaultJobId?: string;
}
const EXPORT_FORMATS: {
type: ExportType;
label: string;
desc: string;
icon: React.ElementType;
badgeColor: string;
}[] = [
{
type: "CSV",
label: "CSV (Comma-Separated)",
desc: "Bảng tính chuẩn, phân tích dữ liệu nhanh",
icon: FileText,
badgeColor: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
},
{
type: "JSON",
label: "JSON (Structured)",
desc: "Cấu trúc lồng ghép, tích hợp API & lập trình",
icon: FileJson,
badgeColor: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
},
{
type: "XLSX",
label: "Excel Spreadsheet (XLSX)",
desc: "Định dạng bảng Microsoft Excel chuyên nghiệp",
icon: FileSpreadsheet,
badgeColor: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
},
{
type: "MARKDOWN",
label: "Markdown (.md)",
desc: "Tài liệu văn bản sạch cho RAG và mô hình LLM",
icon: FileCode,
badgeColor: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
},
{
type: "ZIP",
label: "ZIP Archive",
desc: "Gói nén toàn bộ tài nguyên hình ảnh và tệp",
icon: Archive,
badgeColor: "bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/20",
},
];
export function CreateExportModal({
isOpen,
onClose,
defaultJobId = "",
}: CreateExportModalProps) {
const { t } = useLanguage();
const { data: jobsData } = useCrawlJobs({ limit: 10 });
const createExportMutation = useCreateCrawlExport();
const [selectedJobId, setSelectedJobId] = useState(defaultJobId);
const [selectedFormat, setSelectedFormat] = useState<ExportType>("CSV");
const [customFileName, setCustomFileName] = useState("");
const jobs = jobsData?.items || [];
const completedJobs = jobs.filter((j) => j.status === "COMPLETED");
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedJobId) return;
await createExportMutation.mutateAsync({
jobId: selectedJobId,
exportType: selectedFormat,
fileName: customFileName.trim() ? customFileName.trim() : undefined,
});
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-xl flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Download className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
{t.exports.modal.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.exports.modal.desc}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="py-4 space-y-4">
{/* Job Selection */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.exports.modal.selectJob}
</Label>
{completedJobs.length > 0 ? (
<div className="space-y-2 max-h-36 overflow-y-auto pr-1">
{completedJobs.map((job) => (
<button
key={job.id}
type="button"
onClick={() => setSelectedJobId(job.id)}
className={`w-full flex items-center justify-between p-2.5 rounded-2xl border text-left text-xs transition-colors cursor-pointer ${
selectedJobId === job.id
? "border-emerald-500/40 bg-emerald-500/10 text-foreground shadow-sm"
: "border-border/70 bg-card/60 hover:bg-muted/40 text-muted-foreground"
}`}
>
<div className="truncate min-w-0 pr-2">
<p className="font-semibold text-foreground truncate">{job.startUrl}</p>
<p className="text-[10px] text-muted-foreground font-mono truncate">
ID: {job.id.slice(0, 16)}...{job.successPages} trang
</p>
</div>
{selectedJobId === job.id && (
<Check className="h-4 w-4 text-emerald-500 shrink-0" />
)}
</button>
))}
</div>
) : (
<Input
placeholder="Nhập mã Crawl Job ID..."
value={selectedJobId}
onChange={(e) => setSelectedJobId(e.target.value)}
className="rounded-2xl text-xs"
/>
)}
</div>
{/* Format Selection Cards */}
<div className="space-y-2 pt-2 border-t border-border/60">
<Label className="text-xs font-semibold">
{t.exports.modal.selectFormat}
</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{EXPORT_FORMATS.map((fmt) => {
const Icon = fmt.icon;
const isSelected = selectedFormat === fmt.type;
return (
<button
key={fmt.type}
type="button"
onClick={() => setSelectedFormat(fmt.type)}
className={`flex items-start gap-2.5 p-3 rounded-2xl border text-left transition-all cursor-pointer ${
isSelected
? "border-emerald-500/40 bg-emerald-500/10 shadow-sm"
: "border-border/70 bg-muted/20 hover:bg-muted/40"
}`}
>
<div className={`p-1.5 rounded-xl border ${fmt.badgeColor} shrink-0`}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground">
{fmt.type}
</span>
{isSelected && <Check className="h-3.5 w-3.5 text-emerald-500" />}
</div>
<p className="text-[10px] text-muted-foreground line-clamp-1 mt-0.5">
{fmt.desc}
</p>
</div>
</button>
);
})}
</div>
</div>
{/* Custom File Name (Optional) */}
<div className="space-y-1 pt-1">
<Label className="text-[11px] text-muted-foreground">
Tên tệp tùy chỉnh (Tùy chọn)
</Label>
<Input
value={customFileName}
onChange={(e) => setCustomFileName(e.target.value)}
placeholder="crawl_dataset_custom"
className="rounded-2xl text-xs"
/>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-3 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
>
{t.exports.modal.cancel}
</Button>
<Button
type="submit"
disabled={!selectedJobId || createExportMutation.isPending}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{createExportMutation.isPending
? t.exports.modal.submitting
: t.exports.modal.submit}
</Button>
</div>
</form>
</div>
</div>
);
}
This diff is collapsed.
"use client";
import React from "react";
import Link from "next/link";
import {
X,
History,
CheckCircle2,
Clock,
ExternalLink,
AlertCircle,
RefreshCw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { CrawlSchedule } from "@/types/crawl-schedule";
import { useScheduleHistory } from "@/hooks/use-crawl-schedules";
import { useLanguage } from "@/providers/language-provider";
interface ScheduleHistoryModalProps {
isOpen: boolean;
onClose: () => void;
schedule: CrawlSchedule | null;
}
export function ScheduleHistoryModal({
isOpen,
onClose,
schedule,
}: ScheduleHistoryModalProps) {
const { t, locale } = useLanguage();
const {
data: historyData,
isLoading,
isError,
refetch,
} = useScheduleHistory(schedule?.id || "", { limit: 20 });
if (!isOpen || !schedule) return null;
const jobs = historyData?.items || [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-2xl max-h-[85vh] flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<History className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
{t.schedules.historyModal.title}
</h2>
<p className="text-xs text-muted-foreground truncate max-w-md">
{schedule.name} ({schedule.startUrl})
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content Table */}
<div className="flex-1 overflow-y-auto py-4">
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-14 rounded-2xl bg-muted/40 animate-pulse" />
))}
</div>
) : isError ? (
<div className="p-6 text-center rounded-2xl border border-red-500/20 bg-red-500/5 space-y-2">
<p className="text-xs text-red-500">Đã có lỗi khi tải lịch sử job.</p>
<Button size="sm" variant="outline" onClick={() => refetch()} className="rounded-xl text-xs">
<RefreshCw className="h-3.5 w-3.5 mr-1" />
{t.templates.retry}
</Button>
</div>
) : jobs.length === 0 ? (
<div className="py-12 text-center rounded-2xl border border-dashed border-border/70 space-y-2">
<Clock className="h-8 w-8 text-muted-foreground mx-auto opacity-50" />
<p className="text-xs text-muted-foreground">
{t.schedules.historyModal.empty}
</p>
</div>
) : (
<div className="space-y-2.5">
{jobs.map((job) => (
<div
key={job.id}
className="flex items-center justify-between p-3.5 rounded-2xl border border-border/70 bg-card/60 hover:bg-muted/40 transition-colors"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-bold text-foreground">
{job.id.slice(0, 16)}...
</span>
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 text-[10px] font-semibold border border-emerald-500/20">
{job.status}
</span>
</div>
<p className="text-[11px] text-muted-foreground">
{job.successPages} / {job.totalPages} trang •{" "}
{new Date(job.createdAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{
hour: "2-digit",
minute: "2-digit",
day: "numeric",
month: "short",
}
)}
</p>
</div>
<Link
href={`/crawl-jobs/${job.id}`}
className="inline-flex items-center gap-1 rounded-xl px-2.5 py-1.5 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
>
<span>{t.schedules.historyModal.viewDetail}</span>
<ExternalLink className="h-3 w-3" />
</Link>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end pt-3 border-t border-border/60">
<Button
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs h-9 px-5 cursor-pointer"
>
{t.schedules.historyModal.close}
</Button>
</div>
</div>
</div>
);
}
This diff is collapsed.
"use client";
import React, { useState } from "react";
import {
X,
Play,
CheckCircle2,
AlertCircle,
ExternalLink,
Code2,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ExtractionTemplate } from "@/types/extraction-template";
import { useLanguage } from "@/providers/language-provider";
interface TemplatePreviewModalProps {
isOpen: boolean;
onClose: () => void;
template: ExtractionTemplate | null;
}
export function TemplatePreviewModal({
isOpen,
onClose,
template,
}: TemplatePreviewModalProps) {
const { t } = useLanguage();
const [testUrl, setTestUrl] = useState("");
const [isSimulating, setIsSimulating] = useState(false);
const [simulatedData, setSimulatedData] = useState<Record<string, unknown> | null>(null);
React.useEffect(() => {
if (template) {
setTestUrl(`https://${template.domain}/sample-article-2026`);
setSimulatedData(null);
}
}, [template]);
if (!isOpen || !template) return null;
const handleSimulate = () => {
setIsSimulating(true);
setTimeout(() => {
const result: Record<string, string | number | boolean> = {};
template.fields.forEach((f) => {
if (f.name.toLowerCase().includes("title") || f.name.toLowerCase().includes("name")) {
result[f.name] = "Trí Tuệ Nhân Tạo và Cuộc Cách Mạng Công Nghệ Xanh 2026";
} else if (f.name.toLowerCase().includes("price")) {
result[f.name] = "15.990.000 ₫";
} else if (f.name.toLowerCase().includes("content") || f.name.toLowerCase().includes("description")) {
result[f.name] = "Báo cáo tổng kết xu hướng chuyển đổi số và tối ưu hiệu suất tự động hóa trong kỷ nguyên dữ liệu lớn...";
} else if (f.name.toLowerCase().includes("author")) {
result[f.name] = "Nguyễn Văn An - Ban Công Nghệ";
} else if (f.name.toLowerCase().includes("date") || f.name.toLowerCase().includes("time")) {
result[f.name] = "2026-09-06T15:30:00Z";
} else if (f.name.toLowerCase().includes("phone")) {
result[f.name] = "+84 987 654 321";
} else if (f.name.toLowerCase().includes("tax")) {
result[f.name] = "0102030405";
} else {
result[f.name] = `Giá trị trích xuất mẫu cho [${f.selector}]`;
}
});
setSimulatedData(result);
setIsSimulating(false);
}, 600);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-xl flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Sparkles className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
{t.templates.preview.title}
</h2>
<p className="text-xs text-muted-foreground truncate max-w-sm">
{template.name} ({template.domain})
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content */}
<div className="py-4 space-y-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.templates.preview.testUrl}
</Label>
<div className="flex gap-2">
<Input
value={testUrl}
onChange={(e) => setTestUrl(e.target.value)}
placeholder="https://..."
className="rounded-2xl text-xs"
/>
<Button
onClick={handleSimulate}
disabled={isSimulating || !testUrl}
className="rounded-2xl text-xs bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 px-4 cursor-pointer shrink-0"
>
<Play className="h-3.5 w-3.5 mr-1" />
{isSimulating ? t.templates.preview.simulating : t.templates.preview.simulateBtn}
</Button>
</div>
</div>
{/* Configured Fields Summary */}
<div className="p-3 rounded-2xl border border-emerald-500/15 bg-muted/30 space-y-1.5">
<p className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
{t.templates.modal.fieldsTitle} ({template.fields.length})
</p>
<div className="flex flex-wrap gap-1.5">
{template.fields.map((f, i) => (
<span
key={i}
className="rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 text-[11px] font-mono border border-emerald-500/20"
>
{f.name} <span className="opacity-60 text-[10px]">({f.attr})</span>
</span>
))}
</div>
</div>
{/* Simulated Output */}
{simulatedData && (
<div className="p-3.5 rounded-2xl border border-emerald-500/20 bg-card space-y-2 animate-in fade-in-50 duration-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs font-semibold text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-4 w-4" />
<span>{t.templates.preview.sampleResult}</span>
</div>
<span className="text-[10px] text-muted-foreground font-mono">
HTTP 200 OK
</span>
</div>
<pre className="p-3 rounded-xl bg-muted/60 text-foreground font-mono text-[11px] overflow-x-auto max-h-48">
{JSON.stringify(simulatedData, null, 2)}
</pre>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end pt-3 border-t border-border/60">
<Button
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs h-9 px-5 cursor-pointer"
>
{t.dialog.cancelBtn}
</Button>
</div>
</div>
</div>
);
}
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { crawlExportService } from "@/services/crawl-export.service";
import { CrawlExportQueryDto, ExportType } from "@/types/export";
import { toast } from "sonner";
export const CRAWL_EXPORTS_KEYS = {
all: ["crawl-exports"] as const,
lists: () => [...CRAWL_EXPORTS_KEYS.all, "list"] as const,
list: (params?: CrawlExportQueryDto) => [...CRAWL_EXPORTS_KEYS.lists(), params] as const,
};
export function useCrawlExportsList(params?: CrawlExportQueryDto) {
return useQuery({
queryKey: CRAWL_EXPORTS_KEYS.list(params),
queryFn: () => crawlExportService.getExports(params),
staleTime: 10000,
});
}
export function useCreateCrawlExport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ jobId, exportType, fileName }: { jobId: string; exportType: ExportType; fileName?: string }) =>
crawlExportService.createExport(jobId, { exportType, fileName }),
onSuccess: () => {
toast.success("Khởi tạo yêu cầu xuất dữ liệu thành công!");
queryClient.invalidateQueries({ queryKey: CRAWL_EXPORTS_KEYS.all });
},
onError: (error: Error) => {
toast.error(`Không thể tạo yêu cầu xuất: ${error.message}`);
},
});
}
export function useDeleteCrawlExport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlExportService.deleteExport(id),
onSuccess: () => {
toast.success("Đã xóa tệp dữ liệu đã xuất!");
queryClient.invalidateQueries({ queryKey: CRAWL_EXPORTS_KEYS.all });
},
onError: (error: Error) => {
toast.error(`Không thể xóa tệp: ${error.message}`);
},
});
}
export function useDownloadExport() {
return useMutation({
mutationFn: ({ exportId, fileName }: { exportId: string; fileName?: string }) =>
crawlExportService.downloadExport(exportId, fileName),
onSuccess: () => {
toast.success("Đang tải tệp về máy tính của bạn...");
},
onError: (error: Error) => {
toast.error(`Tải tệp thất bại: ${error.message}`);
},
});
}
This diff is collapsed.
This diff is collapsed.
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { extractionTemplateService } from "@/services/extraction-template.service";
import {
CreateExtractionTemplateDto,
UpdateExtractionTemplateDto,
} from "@/types/extraction-template";
import { toast } from "sonner";
export const EXTRACTION_TEMPLATES_KEYS = {
all: ["extraction-templates"] as const,
lists: () => [...EXTRACTION_TEMPLATES_KEYS.all, "list"] as const,
detail: (id: string) => [...EXTRACTION_TEMPLATES_KEYS.all, "detail", id] as const,
};
export function useExtractionTemplatesList() {
return useQuery({
queryKey: EXTRACTION_TEMPLATES_KEYS.lists(),
queryFn: () => extractionTemplateService.getTemplates(),
staleTime: 30000,
});
}
export function useExtractionTemplate(id: string) {
return useQuery({
queryKey: EXTRACTION_TEMPLATES_KEYS.detail(id),
queryFn: () => extractionTemplateService.getTemplateById(id),
enabled: !!id,
});
}
export function useCreateExtractionTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateExtractionTemplateDto) =>
extractionTemplateService.createTemplate(dto),
onSuccess: () => {
toast.success("Tạo mẫu bóc tách dữ liệu thành công!");
queryClient.invalidateQueries({ queryKey: EXTRACTION_TEMPLATES_KEYS.all });
},
onError: (error: Error) => {
toast.error(`Không thể tạo mẫu: ${error.message}`);
},
});
}
export function useUpdateExtractionTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: UpdateExtractionTemplateDto }) =>
extractionTemplateService.updateTemplate(id, dto),
onSuccess: (_, { id }) => {
toast.success("Cập nhật mẫu bóc tách thành công!");
queryClient.invalidateQueries({ queryKey: EXTRACTION_TEMPLATES_KEYS.all });
queryClient.invalidateQueries({ queryKey: EXTRACTION_TEMPLATES_KEYS.detail(id) });
},
onError: (error: Error) => {
toast.error(`Không thể cập nhật mẫu: ${error.message}`);
},
});
}
export function useDeleteExtractionTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => extractionTemplateService.deleteTemplate(id),
onSuccess: () => {
toast.success("Đã xóa mẫu bóc tách thành công!");
queryClient.invalidateQueries({ queryKey: EXTRACTION_TEMPLATES_KEYS.all });
},
onError: (error: Error) => {
toast.error(`Không thể xóa mẫu: ${error.message}`);
},
});
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -7,7 +7,7 @@ export const extractionFieldSchema = z.object({ ...@@ -7,7 +7,7 @@ export const extractionFieldSchema = z.object({
.string() .string()
.trim() .trim()
.min(1, 'Thuộc tính không được để trống (dùng "innerText" hoặc "text" cho nội dung văn bản)'), .min(1, 'Thuộc tính không được để trống (dùng "innerText" hoặc "text" cho nội dung văn bản)'),
required: z.boolean().default(false), required: z.boolean(),
}); });
export const createExtractionTemplateSchema = z.object({ export const createExtractionTemplateSchema = z.object({
......
...@@ -4,3 +4,5 @@ export * from "./crawl-schedule.schema"; ...@@ -4,3 +4,5 @@ export * from "./crawl-schedule.schema";
export * from "./extraction-template.schema"; export * from "./extraction-template.schema";
export * from "./export.schema"; export * from "./export.schema";
export * from "./crawler.schema"; export * from "./crawler.schema";
export * from "./developer.schema";
export * from "./profile.schema";
import { z } from "zod";
export const deactivateAccountSchema = z.object({
password: z.string().min(1, "Vui lòng nhập mật khẩu xác nhận"),
});
export type DeactivateAccountInput = z.infer<typeof deactivateAccountSchema>;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment