Commit daa7768f authored by Lê Bảo Hồng Đức's avatar Lê Bảo Hồng Đức

Merge branch 'feat/duck' into 'develop-news'

fix

See merge request !51
parents 8d514056 080821e5
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function CategoriesPage() {
const router = useRouter();
useEffect(() => {
router.replace('/admin/header-config');
}, [router]);
return null;
}
'use client';
import React from 'react';
import Link from 'next/link';
import { useGetNewsAdmin } from '@/api/endpoints/news';
import { useGetOrganizations } from '@/api/endpoints/organizations';
import { useGetContact } from '@/api/endpoints/contact';
import { useGetNewsPageConfigGetHierarchical } from '@/api/endpoints/news-page-config';
import { GetNewsResponseType } from '@/api/types/news';
import { GetNewsPageConfigResponseType } from '@/api/types/news-page-config';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import {
ArrowRight,
BarChart3,
Building2,
FileText,
Layers,
Mail,
Newspaper,
Users,
} from 'lucide-react';
function extractCount(
value: unknown,
): number | undefined {
if (!value || typeof value !== 'object') return undefined;
const source = value as {
responseData?: { count?: number };
data?: { count?: number; responseData?: { count?: number } };
};
return (
source.responseData?.count ??
source.data?.responseData?.count ??
source.data?.count
);
}
interface StatCardProps {
title: string;
value: number | string | undefined;
icon: React.ReactNode;
href: string;
color: string;
isLoading?: boolean;
}
function StatCard({ title, value, icon, href, color, isLoading }: StatCardProps) {
return (
<Link href={href}>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-gray-500">{title}</p>
{isLoading ? (
<Skeleton className="mt-2 h-8 w-20" />
) : (
<p className="mt-1 text-3xl font-bold text-gray-900">{value ?? '—'}</p>
)}
</div>
<div
className={`flex h-14 w-14 items-center justify-center rounded-2xl text-white shadow-sm ${color}`}
>
{icon}
</div>
</div>
<div className="mt-4 flex items-center gap-1 text-xs text-[#063e8e] opacity-0 transition-opacity group-hover:opacity-100">
<span>Xem chi tiết</span>
<ArrowRight className="h-3 w-3" />
</div>
</CardContent>
</Card>
</Link>
);
}
export default function DashboardPage() {
const { data: newsData, isLoading: newsLoading } =
useGetNewsAdmin<GetNewsResponseType>({ pageSize: '1' });
const { data: configData, isLoading: configLoading } =
useGetNewsPageConfigGetHierarchical<GetNewsPageConfigResponseType>();
const { data: orgData, isLoading: orgLoading } = useGetOrganizations({ pageSize: '1' });
const { data: contactData, isLoading: contactLoading } = useGetContact();
const stats = [
{
title: 'Tổng bài viết',
value: newsData?.responseData?.count,
icon: <Newspaper className="h-7 w-7" />,
href: '/admin/news',
color: 'bg-[#063e8e]',
isLoading: newsLoading,
},
{
title: 'Cấu hình Danh mục',
value: configData?.responseData ? 'Đã cấu hình' : '—',
icon: <Layers className="h-7 w-7" />,
href: '/admin/header-config',
color: 'bg-violet-500',
isLoading: configLoading,
},
{
title: 'Hội viên',
value: extractCount(orgData),
icon: <Users className="h-7 w-7" />,
href: '/admin/members',
color: 'bg-orange-500',
isLoading: orgLoading,
},
{
title: 'Liên hệ / Email',
value: extractCount(contactData),
icon: <Mail className="h-7 w-7" />,
href: '/admin/emails',
color: 'bg-pink-500',
isLoading: contactLoading,
},
];
const quickLinks = [
{ label: 'Thêm bài viết mới', href: '/admin/news/new', icon: <FileText className="h-4 w-4" /> },
{ label: 'Cấu hình menu Header', href: '/admin/header-config', icon: <Layers className="h-4 w-4" /> },
{ label: 'Quản lý Hội viên', href: '/admin/members', icon: <Users className="h-4 w-4" /> },
{ label: 'Đối tác', href: '/admin/partners', icon: <Building2 className="h-4 w-4" /> },
{ label: 'Thông tin website', href: '/admin/website-config', icon: <BarChart3 className="h-4 w-4" /> },
];
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight text-gray-900">Tổng quan hệ thống</h2>
<p className="mt-1 text-sm font-medium text-gray-600">
Chào mừng trở lại! Đây là tóm tắt hoạt động của VCCI News.
</p>
</div>
<Badge variant="outline" className="border-[#063e8e]/30 text-[#063e8e]">
Cập nhật: {new Date().toLocaleDateString('vi-VN')}
</Badge>
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-4">
{stats.map((item) => (
<StatCard key={item.href} {...item} />
))}
</div>
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold text-gray-800">Truy cập nhanh</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{quickLinks.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex flex-col items-center gap-2 rounded-xl border border-[#063e8e]/15 p-4 text-center text-sm text-gray-700 transition-all hover:border-[#063e8e]/40 hover:bg-[#063e8e]/5 hover:text-[#063e8e]"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#063e8e]/10 text-[#063e8e]">
{item.icon}
</div>
<span className="text-xs font-medium leading-tight">{item.label}</span>
</Link>
))}
</div>
</CardContent>
</Card>
</div>
);
}
'use client';
import React, { useState } from 'react';
import { useGetContact } from '@/api/endpoints/contact';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Search, Mail } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import dayjs from 'dayjs';
export default function EmailsPage() {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const pageSize = 20;
const { data, isLoading } = useGetContact({} as any);
const allRows = (data as any)?.responseData?.rows ?? (data as any)?.data?.rows ?? [];
const filtered = search
? allRows.filter(
(r: any) =>
r.email?.includes(search) ||
r.full_name?.toLowerCase().includes(search.toLowerCase()) ||
r.organization_name?.toLowerCase().includes(search.toLowerCase()),
)
: allRows;
const total = filtered.length;
const totalPages = Math.ceil(total / pageSize);
const rows = filtered.slice((page - 1) * pageSize, page * pageSize);
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-gray-800">Quản lý Email nhận thông tin</h2>
<p className="text-sm text-gray-500 mt-1">
Danh sách các địa chỉ email / liên hệ nhận thông tin từ VCCI.
</p>
</div>
<Badge variant="outline" className="border-pink-300 text-pink-600 text-sm px-3 py-1">
<Mail className="h-3.5 w-3.5 mr-1" />
{total} email
</Badge>
</div>
<div className="relative w-72 mb-4">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<Input
className="pl-9"
placeholder="Tìm email, tên, tổ chức..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8">#</TableHead>
<TableHead>Email</TableHead>
<TableHead>Họ tên</TableHead>
<TableHead>Tổ chức</TableHead>
<TableHead>Số điện thoại</TableHead>
<TableHead>Nhu cầu</TableHead>
<TableHead>Ngày gửi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 7 }).map((_, j) => (
<TableCell key={j}><Skeleton className="h-4 w-full" /></TableCell>
))}
</TableRow>
))
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-gray-400 py-10 text-sm">
Chưa có thông tin email nào.
</TableCell>
</TableRow>
) : rows.map((item: any, idx: number) => (
<TableRow key={item.id ?? idx}>
<TableCell className="text-gray-400 text-sm">{(page - 1) * pageSize + idx + 1}</TableCell>
<TableCell>
<a href={`mailto:${item.email}`} className="text-sm font-medium text-blue-600 hover:underline">
{item.email}
</a>
</TableCell>
<TableCell className="text-sm">{item.full_name || '—'}</TableCell>
<TableCell className="text-sm text-gray-600 max-w-40 truncate">
{item.organization_name || '—'}
</TableCell>
<TableCell className="text-sm text-gray-600">{item.phone || '—'}</TableCell>
<TableCell className="text-sm text-gray-500 max-w-[140px] truncate">
{item.demand || '—'}
</TableCell>
<TableCell className="text-gray-400 text-sm">
{item.created_at ? dayjs(item.created_at).format('DD/MM/YYYY') : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Trước</Button>
<span className="text-sm text-gray-500 self-center">{page} / {totalPages}</span>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>Sau</Button>
</div>
)}
</div>
);
}
......@@ -158,7 +158,7 @@ export function HeaderCategoryTable({
</TableRow>
) : (
rows.map((item, index) => {
const hasChildren = rows.some((entry) => entry.parentId === item.id);
const hasChildren = item.children.length > 0;
const isExpanded = expanded[item.id] ?? true;
const canCreateChild = !item.parent_id && item.type === "category";
const canManagePosts = item.type === "page" || item.type === "news";
......
import { AdminMemberForm } from "@/components/admin/member-form";
interface AdminMemberDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function AdminMemberDetailPage({ params }: AdminMemberDetailPageProps) {
const { id } = await params;
return <AdminMemberForm memberId={id} />;
}
"use client";
import * as React from "react";
import { Edit, Plus, Save, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
type MemberField,
createMemberFieldId,
persistMemberFields,
readMemberFields,
} from "@/mockdata/members";
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface FieldFormDialogProps {
open: boolean;
initial: MemberField | null;
onOpenChange: (open: boolean) => void;
onSave: (data: { id?: string; name: string }) => void;
}
function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFormDialogProps) {
const [name, setName] = React.useState("");
React.useEffect(() => {
if (open) setName(initial?.name ?? "");
}, [open, initial]);
const handleSave = () => {
const trimmed = name.trim();
if (!trimmed) {
toast.error("Vui lòng nhập tên lĩnh vực");
return;
}
onSave({ id: initial?.id, name: trimmed });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md border-[#063e8e]/15 bg-white">
<DialogHeader>
<DialogTitle className="text-[#063e8e]">
{initial ? "Chỉnh sửa lĩnh vực" : "Thêm lĩnh vực mới"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label className="text-gray-700">
Tên lĩnh vực <span className="text-red-500">*</span>
</Label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Nhập tên lĩnh vực..."
className={fieldClassName}
onKeyDown={(event) => event.key === "Enter" && handleSave()}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
className="border-[#063e8e]/15 text-gray-700"
onClick={() => onOpenChange(false)}
>
<X className="mr-2 h-4 w-4" />
Hủy
</Button>
<Button
type="button"
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={handleSave}
>
<Save className="mr-2 h-4 w-4" />
Lưu
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export default function AdminMemberFieldsPage() {
const [items, setItems] = React.useState<MemberField[]>([]);
const [search, setSearch] = React.useState("");
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editTarget, setEditTarget] = React.useState<MemberField | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<MemberField | null>(null);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
setItems(readMemberFields());
setReady(true);
}, []);
const filtered = React.useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) => item.name.toLowerCase().includes(keyword));
}, [items, search]);
const openCreate = () => {
setEditTarget(null);
setDialogOpen(true);
};
const openEdit = (item: MemberField) => {
setEditTarget(item);
setDialogOpen(true);
};
const handleSave = (data: { id?: string; name: string }) => {
let next: MemberField[];
if (data.id) {
next = items.map((item) => (item.id === data.id ? { ...item, name: data.name } : item));
toast.success("Đã cập nhật lĩnh vực");
} else {
next = [...items, { id: createMemberFieldId(), name: data.name }];
toast.success("Đã thêm lĩnh vực mới");
}
setItems(next);
persistMemberFields(next);
setDialogOpen(false);
};
const handleDelete = () => {
if (!deleteTarget) return;
const next = items.filter((item) => item.id !== deleteTarget.id);
setItems(next);
persistMemberFields(next);
toast.success("Đã xóa lĩnh vực");
setDeleteTarget(null);
};
return (
<div className="space-y-8">
<AdminTableLayout
searchValue={search}
searchPlaceholder="Tìm kiếm lĩnh vực..."
actionLabel="Thêm lĩnh vực"
actionIcon={<Plus className="mr-2 h-4 w-4" />}
actionMeta={
<div className="text-sm font-medium text-gray-700">
Tổng lĩnh vực: <span className="font-semibold text-[#063e8e]">{items.length}</span>
</div>
}
onSearchChange={setSearch}
onActionClick={openCreate}
>
<Table>
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-16 py-4 text-center text-white">STT</TableHead>
<TableHead className="py-4 text-white">Tên lĩnh vực</TableHead>
<TableHead className="w-[120px] py-4 text-center text-white">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!ready ? (
Array.from({ length: 3 }).map((_, index) => (
<TableRow key={`loading-${index}`}>
<TableCell colSpan={3} className="px-4 py-4">
<div className="h-10 animate-pulse rounded-xl bg-[#063e8e]/10" />
</TableCell>
</TableRow>
))
) : filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-16 text-center text-gray-400">
Không có lĩnh vực nào
</TableCell>
</TableRow>
) : (
filtered.map((item, index) => (
<TableRow
key={item.id}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/3"}
>
<TableCell className="py-3 text-center text-sm text-gray-500">
{index + 1}
</TableCell>
<TableCell className="py-3 text-sm font-medium text-gray-800">
{item.name}
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => openEdit(item)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</AdminTableLayout>
<FieldFormDialog
open={dialogOpen}
initial={editTarget}
onOpenChange={setDialogOpen}
onSave={handleSave}
/>
<AdminDeleteDialog
open={!!deleteTarget}
title="Xóa lĩnh vực"
description={
<>
Bạn có chắc muốn xóa lĩnh vực{" "}
<span className="font-semibold">{deleteTarget?.name}</span>? Hành động này không thể
hoàn tác.
</>
}
onOpenChange={(open) => !open && setDeleteTarget(null)}
onConfirm={handleDelete}
/>
</div>
);
}
This diff is collapsed.
"use client";
import * as React from "react";
import { Edit, Plus, Save, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
type MemberRegion,
createMemberRegionId,
persistMemberRegions,
readMemberRegions,
} from "@/mockdata/members";
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface RegionFormDialogProps {
open: boolean;
initial: MemberRegion | null;
onOpenChange: (open: boolean) => void;
onSave: (data: { id?: string; name: string }) => void;
}
function RegionFormDialog({ open, initial, onOpenChange, onSave }: RegionFormDialogProps) {
const [name, setName] = React.useState("");
React.useEffect(() => {
if (open) setName(initial?.name ?? "");
}, [open, initial]);
const handleSave = () => {
const trimmed = name.trim();
if (!trimmed) {
toast.error("Vui lòng nhập tên khu vực");
return;
}
onSave({ id: initial?.id, name: trimmed });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md border-[#063e8e]/15 bg-white">
<DialogHeader>
<DialogTitle className="text-[#063e8e]">
{initial ? "Chỉnh sửa khu vực" : "Thêm khu vực mới"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label className="text-gray-700">
Tên khu vực <span className="text-red-500">*</span>
</Label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Nhập tên khu vực..."
className={fieldClassName}
onKeyDown={(event) => event.key === "Enter" && handleSave()}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
className="border-[#063e8e]/15 text-gray-700"
onClick={() => onOpenChange(false)}
>
<X className="mr-2 h-4 w-4" />
Hủy
</Button>
<Button
type="button"
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={handleSave}
>
<Save className="mr-2 h-4 w-4" />
Lưu
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export default function AdminMemberRegionsPage() {
const [items, setItems] = React.useState<MemberRegion[]>([]);
const [search, setSearch] = React.useState("");
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editTarget, setEditTarget] = React.useState<MemberRegion | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<MemberRegion | null>(null);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
setItems(readMemberRegions());
setReady(true);
}, []);
const filtered = React.useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) => item.name.toLowerCase().includes(keyword));
}, [items, search]);
const openCreate = () => {
setEditTarget(null);
setDialogOpen(true);
};
const openEdit = (item: MemberRegion) => {
setEditTarget(item);
setDialogOpen(true);
};
const handleSave = (data: { id?: string; name: string }) => {
let next: MemberRegion[];
if (data.id) {
next = items.map((item) => (item.id === data.id ? { ...item, name: data.name } : item));
toast.success("Đã cập nhật khu vực");
} else {
next = [...items, { id: createMemberRegionId(), name: data.name }];
toast.success("Đã thêm khu vực mới");
}
setItems(next);
persistMemberRegions(next);
setDialogOpen(false);
};
const handleDelete = () => {
if (!deleteTarget) return;
const next = items.filter((item) => item.id !== deleteTarget.id);
setItems(next);
persistMemberRegions(next);
toast.success("Đã xóa khu vực");
setDeleteTarget(null);
};
return (
<div className="space-y-8">
<AdminTableLayout
searchValue={search}
searchPlaceholder="Tìm kiếm khu vực..."
actionLabel="Thêm khu vực"
actionIcon={<Plus className="mr-2 h-4 w-4" />}
actionMeta={
<div className="text-sm font-medium text-gray-700">
Tổng khu vực: <span className="font-semibold text-[#063e8e]">{items.length}</span>
</div>
}
onSearchChange={setSearch}
onActionClick={openCreate}
>
<Table>
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-16 py-4 text-center text-white">STT</TableHead>
<TableHead className="py-4 text-white">Tên khu vực</TableHead>
<TableHead className="w-[120px] py-4 text-center text-white">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!ready ? (
Array.from({ length: 3 }).map((_, index) => (
<TableRow key={`loading-${index}`}>
<TableCell colSpan={3} className="px-4 py-4">
<div className="h-10 animate-pulse rounded-xl bg-[#063e8e]/10" />
</TableCell>
</TableRow>
))
) : filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-16 text-center text-gray-400">
Không có khu vực nào
</TableCell>
</TableRow>
) : (
filtered.map((item, index) => (
<TableRow
key={item.id}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/3"}
>
<TableCell className="py-3 text-center text-sm text-gray-500">
{index + 1}
</TableCell>
<TableCell className="py-3 text-sm font-medium text-gray-800">
{item.name}
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => openEdit(item)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</AdminTableLayout>
<RegionFormDialog
open={dialogOpen}
initial={editTarget}
onOpenChange={setDialogOpen}
onSave={handleSave}
/>
<AdminDeleteDialog
open={!!deleteTarget}
title="Xóa khu vực"
description={
<>
Bạn có chắc muốn xóa khu vực{" "}
<span className="font-semibold">{deleteTarget?.name}</span>? Hành động này không thể
hoàn tác.
</>
}
onOpenChange={(open) => !open && setDeleteTarget(null)}
onConfirm={handleDelete}
/>
</div>
);
}
'use client';
import React, { useState } from 'react';
import { useGetOrganizations } from '@/api/endpoints/organizations';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Search, ExternalLink } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import dayjs from 'dayjs';
export default function PartnersPage() {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const pageSize = 20;
// Filter by type = "PARTNER" or use org categories — using filters param
const { data, isLoading } = useGetOrganizations({
currentPage: String(page),
pageSize: String(pageSize),
filters: search ? `name@=${search}` : undefined,
// note: filter by partner type when backend supports it
});
const rows = (data as any)?.responseData?.rows ?? (data as any)?.data?.rows ?? [];
const total = (data as any)?.responseData?.count ?? (data as any)?.data?.count ?? 0;
const totalPages = Math.ceil(total / pageSize);
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-gray-800">Quản lý Đối tác</h2>
<p className="text-sm text-gray-500 mt-1">
Danh sách tổ chức / đối tác liên kết với VCCI.
</p>
</div>
<Badge variant="outline" className="border-[#063e8e]/30 text-[#063e8e] text-sm px-3 py-1">
{total} đối tác
</Badge>
</div>
<div className="relative w-72 mb-4">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<Input
className="pl-9"
placeholder="Tìm theo tên đối tác..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8">#</TableHead>
<TableHead>Tên tổ chức</TableHead>
<TableHead>Mã số thuế</TableHead>
<TableHead>Website</TableHead>
<TableHead>Email</TableHead>
<TableHead>Địa chỉ</TableHead>
<TableHead>Ngày tạo</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 7 }).map((_, j) => (
<TableCell key={j}><Skeleton className="h-4 w-full" /></TableCell>
))}
</TableRow>
))
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-gray-400 py-10 text-sm">
Chưa có đối tác nào.
</TableCell>
</TableRow>
) : rows.map((org: any, idx: number) => (
<TableRow key={org.id ?? idx}>
<TableCell className="text-gray-400 text-sm">{(page - 1) * pageSize + idx + 1}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8 shrink-0">
<AvatarImage src={org.avatar} alt={org.name} />
<AvatarFallback className="bg-violet-100 text-violet-700 text-xs font-bold">
{org.name?.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<p className="font-medium text-sm truncate max-w-[200px]">{org.name}</p>
</div>
</TableCell>
<TableCell className="text-sm text-gray-600">{org.tax_code || '—'}</TableCell>
<TableCell>
{org.website ? (
<a
href={org.website}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1 text-xs text-blue-500 hover:underline"
>
<ExternalLink size={12} />
{org.website.replace(/^https?:\/\//, '')}
</a>
) : <span className="text-gray-300 text-sm"></span>}
</TableCell>
<TableCell className="text-sm text-gray-600">{org.org_email || '—'}</TableCell>
<TableCell className="text-sm text-gray-500 max-w-[140px] truncate">{org.address || '—'}</TableCell>
<TableCell className="text-gray-400 text-sm">
{org.created_at ? dayjs(org.created_at).format('DD/MM/YYYY') : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Trước</Button>
<span className="text-sm text-gray-500 self-center">{page} / {totalPages}</span>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>Sau</Button>
</div>
)}
</div>
);
}
This diff is collapsed.
'use client';
import React, { useEffect, useState } from 'react';
import {
useGetConfig,
usePutConfig,
getGetConfigQueryKey,
} from '@/api/endpoints/website-config';
import { WebConfig } from '@/api/models/webConfig';
import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { Save, Globe, Phone, Mail, MapPin, Link as LinkIcon } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { toast } from 'sonner';
export default function WebsiteConfigPage() {
const qc = useQueryClient();
const { data, isLoading } = useGetConfig({});
const config: WebConfig | undefined =
(data as any)?.responseData ?? (data as any)?.data ?? undefined;
const [form, setForm] = useState<WebConfig>({
name: '',
name_en: '',
address: '',
address_en: '',
logo: '',
link: '',
phone: '',
email: '',
social: '',
});
useEffect(() => {
if (config) {
setForm({
name: config.name ?? '',
name_en: config.name_en ?? '',
address: config.address ?? '',
address_en: config.address_en ?? '',
logo: config.logo ?? '',
link: config.link ?? '',
phone: config.phone ?? '',
email: config.email ?? '',
social: config.social ?? '',
});
}
}, [config]);
const { mutate: save, isPending } = usePutConfig({
mutation: {
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetConfigQueryKey() });
toast.success('Đã lưu thông tin website!');
},
onError: () => {
toast.error('Có lỗi khi lưu thông tin. Vui lòng thử lại.');
},
},
});
const setField = <K extends keyof WebConfig>(key: K, value: WebConfig[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!config?.id) return;
save({ params: { filters: String(config.id) }, data: form });
};
return (
<div className="max-w-2xl space-y-6">
<div>
<h2 className="text-xl font-bold text-gray-800">Thông tin website</h2>
<p className="text-sm text-gray-500 mt-1">
Cập nhật thông tin chung hiển thị trên website VCCI News.
</p>
</div>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Tên website */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Globe className="h-4 w-4 text-[#063e8e]" /> Tên & Logo
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="name">Tên (Tiếng Việt)</Label>
<Input
id="name"
value={form.name}
onChange={(e) => setField('name', e.target.value)}
placeholder="VCCI HCM"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="name_en">Tên (Tiếng Anh)</Label>
<Input
id="name_en"
value={form.name_en}
onChange={(e) => setField('name_en', e.target.value)}
placeholder="VCCI HCM (English)"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="logo">URL Logo</Label>
<div className="flex gap-3 items-start">
<Input
id="logo"
value={form.logo}
onChange={(e) => setField('logo', e.target.value)}
placeholder="https://..."
className="flex-1"
/>
{form.logo && (
<img
src={form.logo}
alt="logo preview"
className="h-10 w-auto border rounded object-contain"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="link">
<LinkIcon className="inline h-3.5 w-3.5 mr-1" />
Đường dẫn website
</Label>
<Input
id="link"
value={form.link}
onChange={(e) => setField('link', e.target.value)}
placeholder="https://vccihn.com"
/>
</div>
</CardContent>
</Card>
<Separator />
{/* Liên hệ */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Phone className="h-4 w-4 text-[#063e8e]" /> Thông tin liên hệ
</CardTitle>
<CardDescription className="text-xs">Hiển thị ở footer và trang liên hệ.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="phone">
<Phone className="inline h-3.5 w-3.5 mr-1" />
Điện thoại
</Label>
<Input
id="phone"
value={form.phone}
onChange={(e) => setField('phone', e.target.value)}
placeholder="028 xxxx xxxx"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">
<Mail className="inline h-3.5 w-3.5 mr-1" />
Email
</Label>
<Input
id="email"
type="email"
value={form.email}
onChange={(e) => setField('email', e.target.value)}
placeholder="info@vcci.com.vn"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="address">
<MapPin className="inline h-3.5 w-3.5 mr-1" />
Địa chỉ (Tiếng Việt)
</Label>
<Input
id="address"
value={form.address}
onChange={(e) => setField('address', e.target.value)}
placeholder="171 Võ Thị Sáu, Quận 3, TP.HCM"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="address_en">
<MapPin className="inline h-3.5 w-3.5 mr-1" />
Địa chỉ (Tiếng Anh)
</Label>
<Input
id="address_en"
value={form.address_en}
onChange={(e) => setField('address_en', e.target.value)}
placeholder="171 Vo Thi Sau, District 3, HCMC"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="social">Mạng xã hội (URL Facebook / Fanpage)</Label>
<Input
id="social"
value={form.social}
onChange={(e) => setField('social', e.target.value)}
placeholder="https://facebook.com/vccihn"
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={isPending || !config?.id} className="bg-[#063e8e] hover:bg-[#063e8e]/90">
<Save size={15} className="mr-2" />
{isPending ? 'Đang lưu...' : 'Lưu thay đổi'}
</Button>
</div>
</form>
)}
</div>
);
}
......@@ -10,6 +10,7 @@ interface AdminTableLayoutProps {
searchPlaceholder?: string;
actionLabel?: string;
actionIcon?: React.ReactNode;
actionMeta?: React.ReactNode;
actionDisabled?: boolean;
children: React.ReactNode;
filters?: React.ReactNode;
......@@ -22,6 +23,7 @@ export function AdminTableLayout({
searchPlaceholder = "Tìm kiếm...",
actionLabel,
actionIcon,
actionMeta,
actionDisabled = false,
children,
filters,
......@@ -41,20 +43,25 @@ export function AdminTableLayout({
{filters}
</div>
{actionLabel ? (
<Button
type="button"
disabled={actionDisabled}
onClick={onActionClick}
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
{actionIcon ?? <Plus className="mr-2 h-4 w-4" />}
{actionLabel}
</Button>
{actionLabel || actionMeta ? (
<div className="flex items-center gap-3 self-start sm:self-auto">
{actionMeta}
{actionLabel ? (
<Button
type="button"
disabled={actionDisabled}
onClick={onActionClick}
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
{actionIcon ?? <Plus className="mr-2 h-4 w-4" />}
{actionLabel}
</Button>
) : null}
</div>
) : null}
</div>
<div className="overflow-hidden rounded-xl border border-[#063e8e]/15 bg-white shadow-sm">
<div className="overflow-hidden rounded-xl border border-[#063e8e]/20 bg-white shadow-sm [&_tbody_td:not(:last-child)]:border-r [&_tbody_td:not(:last-child)]:border-[#063e8e]/20 [&_thead_th:not(:last-child)]:border-r [&_thead_th:not(:last-child)]:border-white/15">
{children}
</div>
</div>
......
This diff is collapsed.
......@@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
import { useMemo, useRef } from "react";
import type { JoditEditorProps } from "jodit-react";
const JoditEditor = dynamic(() => import("jodit-react"), {
const JoditEditor = dynamic(() => import("jodit-react").then((mod) => mod.default), {
ssr: false,
loading: () => (
<div className="flex min-h-[260px] items-center justify-center rounded-2xl border border-[#063e8e]/15 bg-white text-sm text-gray-500">
......
......@@ -2,14 +2,15 @@
import React from 'react';
import { usePathname } from 'next/navigation';
import { Menu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useSidebarStore } from '@/hooks/use-admin-sidebar';
import { Menu } from 'lucide-react';
const routeLabels: Record<string, string> = {
'/admin/dashboard': 'Dashboard',
'/admin/header-config': 'Cấu hình Danh mục',
'/admin/news': 'Quản lý bài viết',
'/admin/videos': 'Quản lý video',
'/admin/members': 'Quản lý Hội viên',
'/admin/partners': 'Quản lý Đối tác',
'/admin/emails': 'Email nhận thông tin',
......@@ -20,7 +21,7 @@ function getTitle(pathname: string): string {
if (routeLabels[pathname]) return routeLabels[pathname];
for (const [prefix, label] of Object.entries(routeLabels)) {
if (pathname.startsWith(prefix + '/')) return label;
if (pathname.startsWith(`${prefix}/`)) return label;
}
return 'Quản trị';
......
......@@ -5,15 +5,12 @@ import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
BarChart3,
Building2,
ChevronDown,
Globe,
Layers,
Mail,
Newspaper,
Settings,
Users,
Video,
} from 'lucide-react';
import logo from '@/assets/VCCI-HCM-logo-VN-2025.png';
import { useSidebarStore } from '@/hooks/use-admin-sidebar';
......@@ -28,27 +25,43 @@ type NavItem = {
};
const navigation: NavItem[] = [
// { name: 'Dashboard', href: '/admin/dashboard', icon: BarChart3 },
{ name: 'Cấu hình danh mục', href: '/admin/header-config', icon: Layers },
{ name: 'Quản lý bài viết', href: '/admin/news', icon: Newspaper },
// { name: 'Quản lý hội viên', href: '/admin/members', icon: Users },
// { name: 'Quản lý đối tác', href: '/admin/partners', icon: Building2 },
// { name: 'Email thông tin', href: '/admin/emails', icon: Mail },
// {
// name: 'Thiết lập',
// icon: Settings,
// children: [{ name: 'Thông tin website', href: '/admin/website-config' }],
// },
{ name: 'Quản lý video', href: '/admin/videos', icon: Video },
{
name: 'Quản lý hội viên',
icon: Users,
children: [
{ name: 'Danh sách hội viên', href: '/admin/members' },
{ name: 'Quản lý lĩnh vực', href: '/admin/members/fields' },
{ name: 'Quản lý khu vực', href: '/admin/members/regions' },
],
},
];
const membersReservedSegments = new Set(['fields', 'regions']);
export function AdminSidebar() {
const pathname = usePathname();
const { isOpen } = useSidebarStore();
const [expandedGroups, setExpandedGroups] = React.useState<Record<string, boolean>>({
'Thiết lập': true,
'Quản lý hội viên': true,
});
const isItemActive = (href: string) => pathname === href || pathname.startsWith(`${href}/`);
const isItemActive = React.useCallback(
(href: string) => {
if (href === '/admin/members') {
if (pathname === href) return true;
if (!pathname.startsWith(`${href}/`)) return false;
const nextSegment = pathname.slice(`${href}/`.length).split('/')[0];
return Boolean(nextSegment) && !membersReservedSegments.has(nextSegment);
}
return pathname === href || pathname.startsWith(`${href}/`);
},
[pathname],
);
const isGroupActive = (children: NavChild[]) => children.some((child) => isItemActive(child.href));
......
"use client";
// ---------------------------------------------------------------------------
// Storage keys
// ---------------------------------------------------------------------------
export const MEMBER_STORAGE_KEY = "vcci-news.admin-members.data.v1";
export const MEMBER_FIELD_STORAGE_KEY = "vcci-news.admin-member-fields.data.v1";
export const MEMBER_REGION_STORAGE_KEY = "vcci-news.admin-member-regions.data.v1";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MemberField {
id: string;
name: string;
}
export interface MemberRegion {
id: string;
name: string;
}
export interface MemberImageRef {
id: string;
name: string;
alt: string;
url: string;
}
import type { AdminNewsContentSection } from "@/mockdata/admin-news";
export interface MemberItem {
id: string;
name: string;
is_featured: boolean;
image: MemberImageRef | null;
region_id: string;
field_id: string;
address: string;
phone: string;
fax: string;
email: string;
website: string;
introduction: AdminNewsContentSection[];
created_at: string;
updated_at: string;
}
export interface MemberFormValues {
id?: string;
name: string;
is_featured: boolean;
image: MemberImageRef | null;
region_id: string;
field_id: string;
address: string;
phone: string;
fax: string;
email: string;
website: string;
introduction: AdminNewsContentSection[];
}
// ---------------------------------------------------------------------------
// ID generators
// ---------------------------------------------------------------------------
export function createMemberId(): string {
return `member-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
export function createMemberFieldId(): string {
return `field-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
export function createMemberRegionId(): string {
return `region-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
// ---------------------------------------------------------------------------
// Seed data
// ---------------------------------------------------------------------------
const SEED_FIELDS: MemberField[] = [
{ id: "field-1", name: "Công nghiệp" },
{ id: "field-2", name: "Thương mại" },
{ id: "field-3", name: "Dịch vụ" },
{ id: "field-4", name: "Nông nghiệp" },
{ id: "field-5", name: "Công nghệ thông tin" },
];
const SEED_REGIONS: MemberRegion[] = [
{ id: "region-1", name: "TP. Hồ Chí Minh" },
{ id: "region-2", name: "Hà Nội" },
{ id: "region-3", name: "Đà Nẵng" },
{ id: "region-4", name: "Cần Thơ" },
{ id: "region-5", name: "Bình Dương" },
];
const SEED_MEMBERS: MemberItem[] = [
{
id: "member-1",
name: "Công ty TNHH ABC",
is_featured: true,
image: null,
region_id: "region-1",
field_id: "field-2",
address: "123 Nguyễn Văn Linh, Quận 7, TP. HCM",
phone: "028 1234 5678",
fax: "028 1234 5679",
email: "contact@abc.vn",
website: "https://abc.vn",
introduction: [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
];
// ---------------------------------------------------------------------------
// Field helpers
// ---------------------------------------------------------------------------
export function getMemberFieldSeed(): MemberField[] {
return SEED_FIELDS;
}
export function readMemberFields(): MemberField[] {
if (typeof window === "undefined") return getMemberFieldSeed();
const raw = window.localStorage.getItem(MEMBER_FIELD_STORAGE_KEY);
if (!raw) return getMemberFieldSeed();
try {
const parsed = JSON.parse(raw) as MemberField[];
return Array.isArray(parsed) && parsed.length > 0 ? parsed : getMemberFieldSeed();
} catch {
return getMemberFieldSeed();
}
}
export function persistMemberFields(fields: MemberField[]): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(MEMBER_FIELD_STORAGE_KEY, JSON.stringify(fields));
}
// ---------------------------------------------------------------------------
// Region helpers
// ---------------------------------------------------------------------------
export function getMemberRegionSeed(): MemberRegion[] {
return SEED_REGIONS;
}
export function readMemberRegions(): MemberRegion[] {
if (typeof window === "undefined") return getMemberRegionSeed();
const raw = window.localStorage.getItem(MEMBER_REGION_STORAGE_KEY);
if (!raw) return getMemberRegionSeed();
try {
const parsed = JSON.parse(raw) as MemberRegion[];
return Array.isArray(parsed) && parsed.length > 0 ? parsed : getMemberRegionSeed();
} catch {
return getMemberRegionSeed();
}
}
export function persistMemberRegions(regions: MemberRegion[]): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(MEMBER_REGION_STORAGE_KEY, JSON.stringify(regions));
}
// ---------------------------------------------------------------------------
// Member helpers
// ---------------------------------------------------------------------------
export function getMemberSeed(): MemberItem[] {
return SEED_MEMBERS;
}
export function readMembers(): MemberItem[] {
if (typeof window === "undefined") return getMemberSeed();
const raw = window.localStorage.getItem(MEMBER_STORAGE_KEY);
if (!raw) return getMemberSeed();
try {
const parsed = JSON.parse(raw) as MemberItem[];
return Array.isArray(parsed) && parsed.length > 0 ? parsed : getMemberSeed();
} catch {
return getMemberSeed();
}
}
export function persistMembers(members: MemberItem[]): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(MEMBER_STORAGE_KEY, JSON.stringify(members));
}
export function cloneMemberFormValues(item: MemberItem): MemberFormValues {
return {
id: item.id,
name: item.name,
is_featured: Boolean(item.is_featured),
image: item.image ? { ...item.image } : null,
region_id: item.region_id,
field_id: item.field_id,
address: item.address,
phone: item.phone,
fax: item.fax,
email: item.email,
website: item.website,
introduction: item.introduction.map((section) => ({
...section,
images: section.images.map((img) => ({ ...img })),
})),
};
}
export const EMPTY_MEMBER_FORM: MemberFormValues = {
name: "",
is_featured: false,
image: null,
region_id: "",
field_id: "",
address: "",
phone: "",
fax: "",
email: "",
website: "",
introduction: [],
};
"use client";
export const VIDEO_STORAGE_KEY = "vcci-news.admin-videos.data.v1";
export interface VideoItem {
id: string;
name: string;
url: string;
}
export interface VideoFormValues {
id?: string;
name: string;
url: string;
}
const VIDEO_SEED: VideoItem[] = [
{
id: "video-1",
name: "Giới thiệu VCCI News",
url: "https://www.youtube.com/watch?v=example001",
},
{
id: "video-2",
name: "Bản tin hoạt động hội viên",
url: "https://www.youtube.com/watch?v=example002",
},
];
export function createVideoId(): string {
return `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
export function getVideoSeed(): VideoItem[] {
return VIDEO_SEED;
}
export function readVideos(): VideoItem[] {
if (typeof window === "undefined") return getVideoSeed();
const raw = window.localStorage.getItem(VIDEO_STORAGE_KEY);
if (!raw) return getVideoSeed();
try {
const parsed = JSON.parse(raw) as VideoItem[];
return Array.isArray(parsed) && parsed.length > 0 ? parsed : getVideoSeed();
} catch {
return getVideoSeed();
}
}
export function persistVideos(items: VideoItem[]): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(VIDEO_STORAGE_KEY, JSON.stringify(items));
}
export const EMPTY_VIDEO_FORM: VideoFormValues = {
name: "",
url: "",
};
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