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"; ...@@ -36,6 +36,14 @@ import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import type { AdminMediaItem } from "@/mockdata/admin-news"; 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 { import {
type BaseConfigBannerItem, type BaseConfigBannerItem,
type BaseConfigBranchItem, type BaseConfigBranchItem,
...@@ -324,7 +332,60 @@ export default function AdminBaseConfigPage() { ...@@ -324,7 +332,60 @@ export default function AdminBaseConfigPage() {
} | null>(null); } | null>(null);
React.useEffect(() => { 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( const mediaMap = React.useMemo(
...@@ -377,7 +438,7 @@ export default function AdminBaseConfigPage() { ...@@ -377,7 +438,7 @@ export default function AdminBaseConfigPage() {
setItemDialogOpen(true); setItemDialogOpen(true);
}; };
const handleSubmitItem = () => { const handleSubmitItem = async () => {
if (!config) return; if (!config) return;
const trimmedName = itemForm.name.trim(); const trimmedName = itemForm.name.trim();
...@@ -393,69 +454,113 @@ export default function AdminBaseConfigPage() { ...@@ -393,69 +454,113 @@ export default function AdminBaseConfigPage() {
setSavingItem(true); 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") { // Fetch file details for preview if needed
nextConfig.logo = { try {
id: editingItemId || currentLogo?.id || createBaseConfigItemId("logo"), const fileInfo = await fetchCmsFileById(savedLogo.file_id);
name: trimmedName, if (fileInfo) {
imageId: itemForm.imageId, const mediaItem = toAdminMediaItem(fileInfo);
isActive: true, setMediaItems((prev) => {
}; const nextMap = new Map(prev.map((e) => [e.id, e]));
} else { nextMap.set(mediaItem.id, mediaItem);
if (editingItemId) { return Array.from(nextMap.values());
nextConfig.banners = nextConfig.banners.map((item) => });
item.id === editingItemId }
? { } catch (fileErr) {
...item, console.error("Error fetching file info after saving logo:", fileErr);
name: trimmedName, }
imageId: itemForm.imageId,
isActive: itemForm.isActive, const nextConfig = cloneBaseConfigData(config);
displayTimeSeconds: itemForm.displayTimeSeconds, nextConfig.logo = {
sortOrder: itemForm.sortOrder, id: savedLogo.id,
} name: savedLogo.logo_name,
: item, 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 { } else {
nextConfig.banners.push({ const nextConfig = cloneBaseConfigData(config);
id: createBaseConfigItemId("banner"), if (editingItemId) {
name: trimmedName, nextConfig.banners = nextConfig.banners.map((item) =>
imageId: itemForm.imageId, item.id === editingItemId
isActive: itemForm.isActive, ? {
displayTimeSeconds: itemForm.displayTimeSeconds, ...item,
sortOrder: itemForm.sortOrder, name: trimmedName,
}); imageId: itemForm.imageId,
setCurrentBannerIndex(Math.max(nextConfig.banners.length - 1, 0)); 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; if (!config || !deleteTarget) return;
const nextConfig = cloneBaseConfigData(config); try {
const nextConfig = cloneBaseConfigData(config);
if (deleteTarget.mode === "logo") { if (deleteTarget.mode === "logo") {
nextConfig.logo = null; await deleteCmsLogo(deleteTarget.id);
} else { nextConfig.logo = null;
nextConfig.banners = nextConfig.banners.filter((item) => item.id !== deleteTarget.id); } else {
setCurrentBannerIndex((previous) => nextConfig.banners = nextConfig.banners.filter((item) => item.id !== deleteTarget.id);
Math.max(0, Math.min(previous, nextConfig.banners.length - 1)), setCurrentBannerIndex((previous) =>
); Math.max(0, Math.min(previous, nextConfig.banners.length - 1)),
} );
}
saveConfig(nextConfig); saveConfig(nextConfig);
toast.success("Đã xóa cấu hình"); toast.success("Đã xóa cấu hình");
setDeleteTarget(null); } 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>( const handleBranchChange = <K extends keyof BaseConfigBranchItem>(
...@@ -516,10 +621,39 @@ export default function AdminBaseConfigPage() { ...@@ -516,10 +621,39 @@ export default function AdminBaseConfigPage() {
setConfig((previous) => (previous ? { ...previous, [key]: value } : previous)); setConfig((previous) => (previous ? { ...previous, [key]: value } : previous));
}; };
const handleSaveWebsiteInfo = () => { const handleSaveWebsiteInfo = async () => {
if (!config) return; 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>( 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 = ( ...@@ -124,11 +124,8 @@ const normalizePersistedAuthState = (
const persistSession = persisted.appPersistSession === true; const persistSession = persisted.appPersistSession === true;
const refreshTokenExpiredAt = getRefreshTokenExpiredAt(persisted.appSession ?? null); const refreshTokenExpiredAt = getRefreshTokenExpiredAt(persisted.appSession ?? null);
const hasUsableSession = const hasUsableSession =
persistSession &&
Boolean(persisted.appRefreshToken) && Boolean(persisted.appRefreshToken) &&
(!refreshTokenExpiredAt || refreshTokenExpiredAt > Date.now()) && (!refreshTokenExpiredAt || refreshTokenExpiredAt > Date.now());
(!persisted.appAccessTokenExpired ||
typeof persisted.appAccessTokenExpired === "number");
if (!hasUsableSession) { if (!hasUsableSession) {
return { return {
...@@ -141,25 +138,12 @@ const normalizePersistedAuthState = ( ...@@ -141,25 +138,12 @@ const normalizePersistedAuthState = (
return { return {
...currentState, ...currentState,
...persisted, ...persisted,
appPersistSession: true, appPersistSession: persistSession,
appUserRemember: rememberState, appUserRemember: rememberState,
appIsRefreshing: false, 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>()( const useAuthStore = create<AuthStoreStateType>()(
devtools( devtools(
persist( persist(
...@@ -252,30 +236,44 @@ const useAuthStore = create<AuthStoreStateType>()( ...@@ -252,30 +236,44 @@ const useAuthStore = create<AuthStoreStateType>()(
{ {
name: "app-auth-storage", name: "app-auth-storage",
storage: createJSONStorage(() => ({ 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) => { 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); localStorage.setItem(name, value);
return;
} }
},
removeItem: (name) => {
if (typeof window === "undefined") return;
localStorage.removeItem(name); localStorage.removeItem(name);
sessionStorage.removeItem(name);
}, },
removeItem: (name) => localStorage.removeItem(name),
})), })),
partialize: (state) => ({ partialize: (state) => ({
appPersistSession: state.appPersistSession, appPersistSession: state.appPersistSession,
...(state.appPersistSession appIsLoggedIn: state.appIsLoggedIn,
? { appAccessToken: state.appAccessToken,
appIsLoggedIn: state.appIsLoggedIn, appAccessTokenExpired: state.appAccessTokenExpired,
appAccessToken: state.appAccessToken, appRefreshToken: state.appRefreshToken,
appAccessTokenExpired: state.appAccessTokenExpired, appSession: state.appSession,
appRefreshToken: state.appRefreshToken, appUser: state.appUser,
appSession: state.appSession, appSessionExpiredNotified: state.appSessionExpiredNotified,
appUser: state.appUser,
appSessionExpiredNotified: state.appSessionExpiredNotified,
}
: {}),
appUserRemember: state.appUserRemember, appUserRemember: state.appUserRemember,
}), }),
merge: (persistedState, currentState) => 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