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

Merge branch 'feature/logo-token' into 'develop-news'

feat: implement admin base configuration management page with API and store integration

See merge request !65
parents 67165167 b49044df
......@@ -36,6 +36,14 @@ import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import {
fetchCmsLogos,
createCmsLogo,
updateCmsLogo,
deleteCmsLogo,
type LogoItem,
} from "@/lib/api/logo";
import { fetchCmsFileById, toAdminMediaItem } from "@/lib/api/files";
import {
type BaseConfigBannerItem,
type BaseConfigBranchItem,
......@@ -324,7 +332,60 @@ export default function AdminBaseConfigPage() {
} | null>(null);
React.useEffect(() => {
setConfig(readBaseConfig());
const init = async () => {
// 1. Read base config from local storage as initial state
const localConfig = readBaseConfig();
setConfig(localConfig);
// 2. Fetch active logo from API
try {
const logoData = await fetchCmsLogos();
const activeLogo = logoData.rows?.[0] ?? null;
if (activeLogo) {
// Construct the LogoItem config structure
const apiLogo: BaseConfigLogoItem = {
id: activeLogo.id,
name: activeLogo.logo_name,
imageId: activeLogo.file_id,
isActive: true,
};
// Fetch the file details to construct the image preview URL
try {
const fileInfo = await fetchCmsFileById(activeLogo.file_id);
if (fileInfo) {
const mediaItem = toAdminMediaItem(fileInfo);
setMediaItems((prev) => {
const nextMap = new Map(prev.map((e) => [e.id, e]));
nextMap.set(mediaItem.id, mediaItem);
return Array.from(nextMap.values());
});
}
} catch (fileErr) {
console.error("Error fetching file info for logo:", fileErr);
}
// Update config state with backend data
setConfig((prev) => {
if (!prev) return prev;
const updatedConfig = {
...prev,
logo: apiLogo,
websiteName: activeLogo.logo_name || prev.websiteName,
websiteLink: activeLogo.logo_url || prev.websiteLink,
};
// Also persist to local storage as fallback for other pages (like dashboard)
persistBaseConfig(updatedConfig);
return updatedConfig;
});
}
} catch (err) {
console.error("Error fetching active logo:", err);
}
};
void init();
}, []);
const mediaMap = React.useMemo(
......@@ -377,7 +438,7 @@ export default function AdminBaseConfigPage() {
setItemDialogOpen(true);
};
const handleSubmitItem = () => {
const handleSubmitItem = async () => {
if (!config) return;
const trimmedName = itemForm.name.trim();
......@@ -393,69 +454,113 @@ export default function AdminBaseConfigPage() {
setSavingItem(true);
const nextConfig = cloneBaseConfigData(config);
try {
if (itemDialogMode === "logo") {
let savedLogo: LogoItem;
if (currentLogo?.id) {
savedLogo = await updateCmsLogo(currentLogo.id, {
logo_name: trimmedName,
file_id: itemForm.imageId,
logo_url: config.websiteLink || "",
});
} else {
savedLogo = await createCmsLogo({
logo_name: trimmedName,
file_id: itemForm.imageId,
logo_url: config.websiteLink || "",
});
}
if (itemDialogMode === "logo") {
nextConfig.logo = {
id: editingItemId || currentLogo?.id || createBaseConfigItemId("logo"),
name: trimmedName,
imageId: itemForm.imageId,
isActive: true,
};
} else {
if (editingItemId) {
nextConfig.banners = nextConfig.banners.map((item) =>
item.id === editingItemId
? {
...item,
name: trimmedName,
imageId: itemForm.imageId,
isActive: itemForm.isActive,
displayTimeSeconds: itemForm.displayTimeSeconds,
sortOrder: itemForm.sortOrder,
}
: item,
);
// Fetch file details for preview if needed
try {
const fileInfo = await fetchCmsFileById(savedLogo.file_id);
if (fileInfo) {
const mediaItem = toAdminMediaItem(fileInfo);
setMediaItems((prev) => {
const nextMap = new Map(prev.map((e) => [e.id, e]));
nextMap.set(mediaItem.id, mediaItem);
return Array.from(nextMap.values());
});
}
} catch (fileErr) {
console.error("Error fetching file info after saving logo:", fileErr);
}
const nextConfig = cloneBaseConfigData(config);
nextConfig.logo = {
id: savedLogo.id,
name: savedLogo.logo_name,
imageId: savedLogo.file_id,
isActive: true,
};
nextConfig.websiteName = savedLogo.logo_name;
if (savedLogo.logo_url) {
nextConfig.websiteLink = savedLogo.logo_url;
}
saveConfig(nextConfig);
setItemDialogOpen(false);
toast.success("Đã lưu cấu hình logo");
} else {
nextConfig.banners.push({
id: createBaseConfigItemId("banner"),
name: trimmedName,
imageId: itemForm.imageId,
isActive: itemForm.isActive,
displayTimeSeconds: itemForm.displayTimeSeconds,
sortOrder: itemForm.sortOrder,
});
setCurrentBannerIndex(Math.max(nextConfig.banners.length - 1, 0));
const nextConfig = cloneBaseConfigData(config);
if (editingItemId) {
nextConfig.banners = nextConfig.banners.map((item) =>
item.id === editingItemId
? {
...item,
name: trimmedName,
imageId: itemForm.imageId,
isActive: itemForm.isActive,
displayTimeSeconds: itemForm.displayTimeSeconds,
sortOrder: itemForm.sortOrder,
}
: item,
);
} else {
nextConfig.banners.push({
id: createBaseConfigItemId("banner"),
name: trimmedName,
imageId: itemForm.imageId,
isActive: itemForm.isActive,
displayTimeSeconds: itemForm.displayTimeSeconds,
sortOrder: itemForm.sortOrder,
});
setCurrentBannerIndex(Math.max(nextConfig.banners.length - 1, 0));
}
saveConfig(nextConfig);
setItemDialogOpen(false);
toast.success("Đã lưu cấu hình banner");
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Không thể lưu cấu hình");
} finally {
setSavingItem(false);
}
saveConfig(nextConfig);
setSavingItem(false);
setItemDialogOpen(false);
toast.success(
itemDialogMode === "logo"
? "Đã lưu cấu hình logo"
: "Đã lưu cấu hình banner",
);
};
const handleDeleteItem = () => {
const handleDeleteItem = async () => {
if (!config || !deleteTarget) return;
const nextConfig = cloneBaseConfigData(config);
try {
const nextConfig = cloneBaseConfigData(config);
if (deleteTarget.mode === "logo") {
nextConfig.logo = null;
} else {
nextConfig.banners = nextConfig.banners.filter((item) => item.id !== deleteTarget.id);
setCurrentBannerIndex((previous) =>
Math.max(0, Math.min(previous, nextConfig.banners.length - 1)),
);
}
if (deleteTarget.mode === "logo") {
await deleteCmsLogo(deleteTarget.id);
nextConfig.logo = null;
} else {
nextConfig.banners = nextConfig.banners.filter((item) => item.id !== deleteTarget.id);
setCurrentBannerIndex((previous) =>
Math.max(0, Math.min(previous, nextConfig.banners.length - 1)),
);
}
saveConfig(nextConfig);
toast.success("Đã xóa cấu hình");
setDeleteTarget(null);
saveConfig(nextConfig);
toast.success("Đã xóa cấu hình");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Không thể xóa cấu hình");
} finally {
setDeleteTarget(null);
}
};
const handleBranchChange = <K extends keyof BaseConfigBranchItem>(
......@@ -516,10 +621,39 @@ export default function AdminBaseConfigPage() {
setConfig((previous) => (previous ? { ...previous, [key]: value } : previous));
};
const handleSaveWebsiteInfo = () => {
const handleSaveWebsiteInfo = async () => {
if (!config) return;
saveConfig(config);
toast.success("Đã lưu thông tin website");
setSavingItem(true);
try {
if (currentLogo?.id) {
const updated = await updateCmsLogo(currentLogo.id, {
logo_name: config.websiteName,
file_id: currentLogo.imageId,
logo_url: config.websiteLink,
});
const nextConfig = cloneBaseConfigData(config);
nextConfig.logo = {
id: updated.id,
name: updated.logo_name,
imageId: updated.file_id,
isActive: true,
};
nextConfig.websiteName = updated.logo_name;
if (updated.logo_url) {
nextConfig.websiteLink = updated.logo_url;
}
saveConfig(nextConfig);
} else {
saveConfig(config);
}
toast.success("Đã lưu thông tin website");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Không thể lưu thông tin website");
} finally {
setSavingItem(false);
}
};
const handleSocialChange = <K extends keyof BaseConfigSocialItem>(
......
"use client";
import { useCustomClient } from "@/api/mutator/custom-client";
export interface LogoItem {
id: string;
logo_name: string;
logo_url: string | null;
file_id: string;
created_at: string;
created_by?: string | null;
updated_at: string;
updated_by?: string | null;
}
export interface LogoListResult {
rows: LogoItem[];
count: number;
page: number;
pageSize: number;
}
interface LogoEnvelope<T> {
message?: string;
message_en?: string;
responseData?: T;
data?: T;
error?: string;
status?: string;
}
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const readMessage = (payload: unknown) => {
if (!isObject(payload)) return "Yêu cầu thất bại";
if (typeof payload.message === "string" && payload.message.trim()) return payload.message;
if (typeof payload.error === "string" && payload.error.trim()) return payload.error;
return "Yêu cầu thất bại";
};
const authHeaders = (withJson = true) => {
const headers = new Headers();
if (withJson) {
headers.set("Content-Type", "application/json");
}
return headers;
};
async function logoRequest<T>(path: string, init?: RequestInit): Promise<T> {
const payload = await useCustomClient<LogoEnvelope<T> | T>(path, {
...init,
headers: init?.headers ?? authHeaders(init?.body !== undefined),
});
if (isObject(payload) && "statusCode" in payload) {
const statusCode = Number(payload.statusCode);
if (statusCode >= 400) {
throw new Error(readMessage(payload));
}
}
if (isObject(payload) && ("responseData" in payload || "data" in payload)) {
return ((payload.responseData ?? payload.data) as T) ?? ({} as T);
}
return (payload ?? {}) as T;
}
export async function fetchCmsLogos() {
return logoRequest<LogoListResult>("/logo?page=1&pageSize=10");
}
export async function createCmsLogo(input: {
logo_name: string;
logo_url?: string | null;
file_id: string;
}) {
return logoRequest<LogoItem>("/logo", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function updateCmsLogo(
id: string,
input: {
logo_name?: string;
logo_url?: string | null;
file_id?: string;
}
) {
return logoRequest<LogoItem>(`/logo/${id}`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export async function deleteCmsLogo(id: string) {
await logoRequest(`/logo/${id}`, {
method: "DELETE",
});
}
......@@ -124,11 +124,8 @@ const normalizePersistedAuthState = (
const persistSession = persisted.appPersistSession === true;
const refreshTokenExpiredAt = getRefreshTokenExpiredAt(persisted.appSession ?? null);
const hasUsableSession =
persistSession &&
Boolean(persisted.appRefreshToken) &&
(!refreshTokenExpiredAt || refreshTokenExpiredAt > Date.now()) &&
(!persisted.appAccessTokenExpired ||
typeof persisted.appAccessTokenExpired === "number");
(!refreshTokenExpiredAt || refreshTokenExpiredAt > Date.now());
if (!hasUsableSession) {
return {
......@@ -141,25 +138,12 @@ const normalizePersistedAuthState = (
return {
...currentState,
...persisted,
appPersistSession: true,
appPersistSession: persistSession,
appUserRemember: rememberState,
appIsRefreshing: false,
};
};
const shouldPersistAuthStorage = (value: string) => {
try {
const parsed = JSON.parse(value) as {
state?: Partial<AuthStoreStateType>;
};
const state = parsed.state ?? {};
return state.appPersistSession === true || state.appUserRemember?.remember === true;
} catch {
return true;
}
};
const useAuthStore = create<AuthStoreStateType>()(
devtools(
persist(
......@@ -252,30 +236,44 @@ const useAuthStore = create<AuthStoreStateType>()(
{
name: "app-auth-storage",
storage: createJSONStorage(() => ({
getItem: (name) => localStorage.getItem(name),
getItem: (name) => {
if (typeof window === "undefined") return null;
return localStorage.getItem(name) ?? sessionStorage.getItem(name);
},
setItem: (name, value) => {
if (shouldPersistAuthStorage(value)) {
if (typeof window === "undefined") return;
try {
const parsed = JSON.parse(value) as {
state?: Partial<AuthStoreStateType>;
};
const state = parsed.state ?? {};
if (state.appPersistSession === true) {
localStorage.setItem(name, value);
sessionStorage.removeItem(name);
} else {
sessionStorage.setItem(name, value);
localStorage.removeItem(name);
}
} catch {
localStorage.setItem(name, value);
return;
}
},
removeItem: (name) => {
if (typeof window === "undefined") return;
localStorage.removeItem(name);
sessionStorage.removeItem(name);
},
removeItem: (name) => localStorage.removeItem(name),
})),
partialize: (state) => ({
appPersistSession: state.appPersistSession,
...(state.appPersistSession
? {
appIsLoggedIn: state.appIsLoggedIn,
appAccessToken: state.appAccessToken,
appAccessTokenExpired: state.appAccessTokenExpired,
appRefreshToken: state.appRefreshToken,
appSession: state.appSession,
appUser: state.appUser,
appSessionExpiredNotified: state.appSessionExpiredNotified,
}
: {}),
appIsLoggedIn: state.appIsLoggedIn,
appAccessToken: state.appAccessToken,
appAccessTokenExpired: state.appAccessTokenExpired,
appRefreshToken: state.appRefreshToken,
appSession: state.appSession,
appUser: state.appUser,
appSessionExpiredNotified: state.appSessionExpiredNotified,
appUserRemember: state.appUserRemember,
}),
merge: (persistedState, currentState) =>
......
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