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

[tag]0.1-vcci

parents 3dc23e79 c152d34d
Pipeline #52566 passed with stages
in 4 minutes and 4 seconds
...@@ -14,6 +14,13 @@ const nextConfig: NextConfig = { ...@@ -14,6 +14,13 @@ const nextConfig: NextConfig = {
}, },
] ]
: []), : []),
// Local development
{
protocol: "http",
hostname: "localhost",
port: "3001",
pathname: "/uploads/**",
},
{ {
protocol: "https", protocol: "https",
hostname: "vcci-hcm.org.vn", // WordPress / media host hostname: "vcci-hcm.org.vn", // WordPress / media host
......
import { useQuery } from "@tanstack/react-query";
import type {
QueryFunction,
QueryKey,
UseQueryOptions,
UseQueryResult,
} from "@tanstack/react-query";
import { useCustomClient } from "../mutator/custom-client";
import type { ErrorType } from "../mutator/custom-client";
import type { PostHistoryItem } from "../types/post-history";
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
interface ApiResponse<T> {
data: T;
status: string;
message: string;
message_en: string;
timeStamp: string;
violations: string | null;
}
type GetPostHistoryResponse = ApiResponse<PostHistoryItem[]>;
export type getApiV10PostIdHistoryResponse200 = {
data: GetPostHistoryResponse;
status: 200;
};
export type getApiV10PostIdHistoryResponse404 = {
data: void;
status: 404;
};
export type getApiV10PostIdHistoryResponseSuccess = getApiV10PostIdHistoryResponse200 & {
headers: Headers;
};
export type getApiV10PostIdHistoryResponseError = getApiV10PostIdHistoryResponse404 & {
headers: Headers;
};
export type getApiV10PostIdHistoryResponse =
| getApiV10PostIdHistoryResponseSuccess
| getApiV10PostIdHistoryResponseError;
export const getGetApiV10PostIdHistoryUrl = (id: string) => {
return `/api/v1.0/post/${id}/history`;
};
export const getApiV10PostIdHistory = async (
id: string,
options?: RequestInit
): Promise<getApiV10PostIdHistoryResponse> => {
return useCustomClient<getApiV10PostIdHistoryResponse>(
getGetApiV10PostIdHistoryUrl(id),
{
...options,
method: "GET",
}
);
};
export const getGetApiV10PostIdHistoryQueryKey = (id: string) => {
return [`/api/v1.0/post/${id}/history`] as const;
};
export const getGetApiV10PostIdHistoryQueryOptions = <
TData = Awaited<ReturnType<typeof getApiV10PostIdHistory>>,
TError = ErrorType<void>
>(
id: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getApiV10PostIdHistory>>,
TError,
TData
>
>;
request?: SecondParameter<typeof useCustomClient>;
}
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetApiV10PostIdHistoryQueryKey(id);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getApiV10PostIdHistory>>
> = ({ signal }) =>
getApiV10PostIdHistory(id, { signal, ...requestOptions });
return {
queryKey,
queryFn,
enabled: !!id,
retry: 3,
retryDelay: 1000,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getApiV10PostIdHistory>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetApiV10PostIdHistoryQueryResult = NonNullable<
Awaited<ReturnType<typeof getApiV10PostIdHistory>>
>;
export type GetApiV10PostIdHistoryQueryError = ErrorType<void>;
export function useGetApiV10PostIdHistory<
TData = Awaited<ReturnType<typeof getApiV10PostIdHistory>>,
TError = ErrorType<void>
>(
id: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getApiV10PostIdHistory>>,
TError,
TData
>
>;
request?: SecondParameter<typeof useCustomClient>;
}
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetApiV10PostIdHistoryQueryOptions(id, options);
const query = useQuery(queryOptions, undefined) as UseQueryResult<
TData,
TError
> & { queryKey: QueryKey };
query.queryKey = queryOptions.queryKey;
return query;
}
This diff is collapsed.
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { ApiResponse } from './apiResponse';
import type { DeleteApiV10PostId200AllOf } from './deleteApiV10PostId200AllOf';
export type DeleteApiV10PostId200 = ApiResponse & DeleteApiV10PostId200AllOf;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type DeleteApiV10PostId200AllOf = {
responseData?: boolean;
};
...@@ -5,8 +5,8 @@ ...@@ -5,8 +5,8 @@
* Generated API documentation * Generated API documentation
* OpenAPI spec version: 1.0.0 * OpenAPI spec version: 1.0.0
*/ */
import type { GetApiV10PostId200AllOfResponseData } from './getApiV10PostId200AllOfResponseData'; import type { Post } from './post';
export type GetApiV10PostId200AllOf = { export type GetApiV10PostId200AllOf = {
responseData?: GetApiV10PostId200AllOfResponseData; responseData?: Post;
}; };
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { ApiResponse } from './apiResponse';
import type { GetApiV10PostIdHistory200AllOf } from './getApiV10PostIdHistory200AllOf';
export type GetApiV10PostIdHistory200 = ApiResponse & GetApiV10PostIdHistory200AllOf;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { GetApiV10PostIdHistory200AllOfResponseDataItem } from './getApiV10PostIdHistory200AllOfResponseDataItem';
export type GetApiV10PostIdHistory200AllOf = {
responseData?: GetApiV10PostIdHistory200AllOfResponseDataItem[];
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { GetApiV10PostIdHistory200AllOfResponseDataItemAction } from './getApiV10PostIdHistory200AllOfResponseDataItemAction';
import type { GetApiV10PostIdHistory200AllOfResponseDataItemActor } from './getApiV10PostIdHistory200AllOfResponseDataItemActor';
import type { GetApiV10PostIdHistory200AllOfResponseDataItemChanges } from './getApiV10PostIdHistory200AllOfResponseDataItemChanges';
import type { GetApiV10PostIdHistory200AllOfResponseDataItemSnapshot } from './getApiV10PostIdHistory200AllOfResponseDataItemSnapshot';
export type GetApiV10PostIdHistory200AllOfResponseDataItem = {
id?: string;
post_id?: string;
action?: GetApiV10PostIdHistory200AllOfResponseDataItemAction;
actor?: GetApiV10PostIdHistory200AllOfResponseDataItemActor;
/**
* { field: { old: x, new: y } }
* @nullable
*/
changes?: GetApiV10PostIdHistory200AllOfResponseDataItemChanges;
/** @nullable */
snapshot?: GetApiV10PostIdHistory200AllOfResponseDataItemSnapshot;
created_at?: string;
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type GetApiV10PostIdHistory200AllOfResponseDataItemAction = typeof GetApiV10PostIdHistory200AllOfResponseDataItemAction[keyof typeof GetApiV10PostIdHistory200AllOfResponseDataItemAction];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const GetApiV10PostIdHistory200AllOfResponseDataItemAction = {
CREATE: 'CREATE',
UPDATE: 'UPDATE',
DELETE: 'DELETE',
} as const;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type GetApiV10PostIdHistory200AllOfResponseDataItemActor = {
id?: string;
email?: string;
/** @nullable */
username?: string | null;
full_name?: string;
/** @nullable */
avatar_url?: string | null;
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
/**
* { field: { old: x, new: y } }
* @nullable
*/
export type GetApiV10PostIdHistory200AllOfResponseDataItemChanges = { [key: string]: unknown } | null;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
/**
* @nullable
*/
export type GetApiV10PostIdHistory200AllOfResponseDataItemSnapshot = { [key: string]: unknown } | null;
...@@ -70,6 +70,8 @@ export * from './deleteApiV10PermissionBulk200AllOfResponseData'; ...@@ -70,6 +70,8 @@ export * from './deleteApiV10PermissionBulk200AllOfResponseData';
export * from './deleteApiV10PositionId200'; export * from './deleteApiV10PositionId200';
export * from './deleteApiV10PositionId200AllOf'; export * from './deleteApiV10PositionId200AllOf';
export * from './deleteApiV10PostCategoryPostIdParams'; export * from './deleteApiV10PostCategoryPostIdParams';
export * from './deleteApiV10PostId200';
export * from './deleteApiV10PostId200AllOf';
export * from './deleteApiV10PostTagPostIdParams'; export * from './deleteApiV10PostTagPostIdParams';
export * from './deleteApiV10RoleBulk200'; export * from './deleteApiV10RoleBulk200';
export * from './deleteApiV10RoleBulk200AllOf'; export * from './deleteApiV10RoleBulk200AllOf';
...@@ -149,6 +151,13 @@ export * from './getApiV10PostId200AllOfResponseDataAllOfCategoryIdsItem'; ...@@ -149,6 +151,13 @@ export * from './getApiV10PostId200AllOfResponseDataAllOfCategoryIdsItem';
export * from './getApiV10PostId200AllOfResponseDataAllOfCategoryIdsItemType'; export * from './getApiV10PostId200AllOfResponseDataAllOfCategoryIdsItemType';
export * from './getApiV10PostId200AllOfResponseDataAllOfStatus'; export * from './getApiV10PostId200AllOfResponseDataAllOfStatus';
export * from './getApiV10PostId200AllOfResponseDataAllOfType'; export * from './getApiV10PostId200AllOfResponseDataAllOfType';
export * from './getApiV10PostIdHistory200';
export * from './getApiV10PostIdHistory200AllOf';
export * from './getApiV10PostIdHistory200AllOfResponseDataItem';
export * from './getApiV10PostIdHistory200AllOfResponseDataItemAction';
export * from './getApiV10PostIdHistory200AllOfResponseDataItemActor';
export * from './getApiV10PostIdHistory200AllOfResponseDataItemChanges';
export * from './getApiV10PostIdHistory200AllOfResponseDataItemSnapshot';
export * from './getApiV10PostParams'; export * from './getApiV10PostParams';
export * from './getApiV10PostTagParams'; export * from './getApiV10PostTagParams';
export * from './getApiV10PostTagPostIdParams'; export * from './getApiV10PostTagPostIdParams';
...@@ -209,6 +218,8 @@ export * from './pageConfigTag'; ...@@ -209,6 +218,8 @@ export * from './pageConfigTag';
export * from './pageConfigTagMutate'; export * from './pageConfigTagMutate';
export * from './pageParameter'; export * from './pageParameter';
export * from './pageSizeParameter'; export * from './pageSizeParameter';
export * from './passwordResetRequest';
export * from './passwordResetRequestMutate';
export * from './patchApiV10NewsletterSubscriptionId200'; export * from './patchApiV10NewsletterSubscriptionId200';
export * from './patchApiV10NewsletterSubscriptionId200AllOf'; export * from './patchApiV10NewsletterSubscriptionId200AllOf';
export * from './patchApiV10TagId200'; export * from './patchApiV10TagId200';
...@@ -316,6 +327,12 @@ export * from './postCategory'; ...@@ -316,6 +327,12 @@ export * from './postCategory';
export * from './postCategoryBulkCreate'; export * from './postCategoryBulkCreate';
export * from './postCategoryMutate'; export * from './postCategoryMutate';
export * from './postContentStructure'; export * from './postContentStructure';
export * from './postHistory';
export * from './postHistoryChanges';
export * from './postHistoryMutate';
export * from './postHistoryMutateChanges';
export * from './postHistoryMutateSnapshot';
export * from './postHistorySnapshot';
export * from './postMutate'; export * from './postMutate';
export * from './postMutateContentStructure'; export * from './postMutateContentStructure';
export * from './postTag'; export * from './postTag';
...@@ -388,6 +405,7 @@ export * from './siteInformationMutate'; ...@@ -388,6 +405,7 @@ export * from './siteInformationMutate';
export * from './siteInformationMutateLinkSocials'; export * from './siteInformationMutateLinkSocials';
export * from './siteInformationResponse'; export * from './siteInformationResponse';
export * from './siteInformationResponseAllOf'; export * from './siteInformationResponseAllOf';
export * from './siteInformationSocial';
export * from './siteInformationSocialLink'; export * from './siteInformationSocialLink';
export * from './siteInformationSocialMutate'; export * from './siteInformationSocialMutate';
export * from './siteInformationUpdateBody'; export * from './siteInformationUpdateBody';
......
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export interface PasswordResetRequest {
id?: string;
email?: string;
note?: string;
status?: string;
resolved_by?: string;
resolved_at?: string;
resolve_note?: string;
created_at?: string;
updated_at?: string;
}
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export interface PasswordResetRequestMutate {
email?: string;
note?: string;
status?: string;
resolved_by?: string;
resolved_at?: string;
resolve_note?: string;
}
...@@ -35,4 +35,5 @@ export interface Post { ...@@ -35,4 +35,5 @@ export interface Post {
location?: string; location?: string;
participation_fee?: string; participation_fee?: string;
content_structure?: PostContentStructure; content_structure?: PostContentStructure;
event_dates?: string;
} }
...@@ -5,8 +5,8 @@ ...@@ -5,8 +5,8 @@
* Generated API documentation * Generated API documentation
* OpenAPI spec version: 1.0.0 * OpenAPI spec version: 1.0.0
*/ */
import type { PostApiV10Post200AllOfResponseData } from './postApiV10Post200AllOfResponseData'; import type { Post } from './post';
export type PostApiV10Post200AllOf = { export type PostApiV10Post200AllOf = {
responseData?: PostApiV10Post200AllOfResponseData; responseData?: Post;
}; };
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { PostHistoryChanges } from './postHistoryChanges';
import type { PostHistorySnapshot } from './postHistorySnapshot';
export interface PostHistory {
id?: string;
post_id?: string;
action?: string;
actor_id?: string;
changes?: PostHistoryChanges;
snapshot?: PostHistorySnapshot;
created_at?: string;
}
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type PostHistoryChanges = { [key: string]: unknown };
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { PostHistoryMutateChanges } from './postHistoryMutateChanges';
import type { PostHistoryMutateSnapshot } from './postHistoryMutateSnapshot';
export interface PostHistoryMutate {
post_id?: string;
action?: string;
actor_id?: string;
changes?: PostHistoryMutateChanges;
snapshot?: PostHistoryMutateSnapshot;
}
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type PostHistoryMutateChanges = { [key: string]: unknown };
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type PostHistoryMutateSnapshot = { [key: string]: unknown };
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type PostHistorySnapshot = { [key: string]: unknown };
...@@ -30,4 +30,5 @@ export interface PostMutate { ...@@ -30,4 +30,5 @@ export interface PostMutate {
location?: string; location?: string;
participation_fee?: string; participation_fee?: string;
content_structure?: PostMutateContentStructure; content_structure?: PostMutateContentStructure;
event_dates?: string;
} }
...@@ -5,8 +5,8 @@ ...@@ -5,8 +5,8 @@
* Generated API documentation * Generated API documentation
* OpenAPI spec version: 1.0.0 * OpenAPI spec version: 1.0.0
*/ */
import type { PutApiV10PostId200AllOfResponseData } from './putApiV10PostId200AllOfResponseData'; import type { Post } from './post';
export type PutApiV10PostId200AllOf = { export type PutApiV10PostId200AllOf = {
responseData?: PutApiV10PostId200AllOfResponseData; responseData?: Post;
}; };
...@@ -5,18 +5,21 @@ ...@@ -5,18 +5,21 @@
* Generated API documentation * Generated API documentation
* OpenAPI spec version: 1.0.0 * OpenAPI spec version: 1.0.0
*/ */
import type { SiteInformationLinkSocials } from './siteInformationLinkSocials';
export interface SiteInformation { export interface SiteInformation {
id?: string; id?: string;
code?: string; hotline?: string;
telephone?: string;
email?: string; email?: string;
address?: string; address?: string;
working_hours?: string;
link_socials?: SiteInformationLinkSocials;
created_at?: string; created_at?: string;
created_by?: string; created_by?: string;
updated_at?: string; updated_at?: string;
updated_by?: string; updated_by?: string;
branch_name?: string;
fax?: string;
googlemap_link?: string;
sort_order?: number;
is_active?: boolean;
website_name?: string;
website_link?: string;
} }
...@@ -5,13 +5,16 @@ ...@@ -5,13 +5,16 @@
* Generated API documentation * Generated API documentation
* OpenAPI spec version: 1.0.0 * OpenAPI spec version: 1.0.0
*/ */
import type { SiteInformationMutateLinkSocials } from './siteInformationMutateLinkSocials';
export interface SiteInformationMutate { export interface SiteInformationMutate {
code?: string; hotline?: string;
telephone?: string;
email?: string; email?: string;
address?: string; address?: string;
working_hours?: string; branch_name?: string;
link_socials?: SiteInformationMutateLinkSocials; fax?: string;
googlemap_link?: string;
sort_order?: number;
is_active?: boolean;
website_name?: string;
website_link?: string;
} }
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export interface SiteInformationSocial {
id?: string;
platform?: string;
label?: string;
icon_key?: string;
url?: string;
sort_order?: number;
is_active?: boolean;
created_at?: string;
updated_at?: string;
}
...@@ -26,4 +26,5 @@ export interface UserAuth { ...@@ -26,4 +26,5 @@ export interface UserAuth {
reset_password_otp_expires_at?: string; reset_password_otp_expires_at?: string;
reset_password_otp_attempts?: number; reset_password_otp_attempts?: number;
reset_password_otp_sent_at?: string; reset_password_otp_sent_at?: string;
must_change_password?: boolean;
} }
...@@ -23,4 +23,5 @@ export interface UserAuthMutate { ...@@ -23,4 +23,5 @@ export interface UserAuthMutate {
reset_password_otp_expires_at?: string; reset_password_otp_expires_at?: string;
reset_password_otp_attempts?: number; reset_password_otp_attempts?: number;
reset_password_otp_sent_at?: string; reset_password_otp_sent_at?: string;
must_change_password?: boolean;
} }
export interface PostHistoryUser {
id: string
email: string
username: string | null
first_name: string | null
last_name: string | null
full_name: string
avatar_url: string | null
}
export type PostHistoryAction = "CREATE" | "UPDATE" | "DELETE"
export interface PostHistoryChanges {
[field: string]: {
old: unknown
new: unknown
}
}
export interface PostHistoryItem {
id: string
post_id: string
action: PostHistoryAction
actor: PostHistoryUser | null
changes: PostHistoryChanges | null
snapshot: Record<string, unknown> | null
created_at: string
}
export interface PostHistoryResponse {
data: PostHistoryItem[]
status: string
message: string
message_en: string
timeStamp: string
violations: string | null
}
...@@ -15,6 +15,11 @@ const formatDateTime = (value: string) => ...@@ -15,6 +15,11 @@ const formatDateTime = (value: string) =>
value ? dayjs(value).format("DD/MM/YYYY HH:mm") : "Đang cập nhật"; value ? dayjs(value).format("DD/MM/YYYY HH:mm") : "Đang cập nhật";
const getEventDateRange = (item: HomePostItem) => { const getEventDateRange = (item: HomePostItem) => {
// Ưu tiên event_dates (ngày cụ thể)
if (item.eventDates && item.eventDates.length > 0) {
return item.eventDates.map((d) => dayjs(d).format("YYYY-MM-DD")).filter(Boolean);
}
const startedAt = item.startedAt ? dayjs(item.startedAt) : null; const startedAt = item.startedAt ? dayjs(item.startedAt) : null;
const endedAt = item.endedAt ? dayjs(item.endedAt) : null; const endedAt = item.endedAt ? dayjs(item.endedAt) : null;
......
...@@ -21,6 +21,7 @@ type FeaturedMember = { ...@@ -21,6 +21,7 @@ type FeaturedMember = {
id: string; id: string;
name: string; name: string;
avatar?: string | null; avatar?: string | null;
org_link?: string | null;
}; };
type FeaturedMembersResponse = { type FeaturedMembersResponse = {
...@@ -46,6 +47,13 @@ const resolveMemberImage = (avatar: string | null | undefined, index: number) => ...@@ -46,6 +47,13 @@ const resolveMemberImage = (avatar: string | null | undefined, index: number) =>
return memberImages[index % memberImages.length] ?? "/img-error.png"; return memberImages[index % memberImages.length] ?? "/img-error.png";
}; };
const getMemberDetailUrl = (orgLink: string | null | undefined) => {
if (orgLink) {
return `${VCCI_HCM_ORIGIN}/giao-thuong-b2b/doanh-nghiep/${orgLink}`;
}
return null;
};
function Members() { function Members() {
const { memberConnectionPosts, categoryLinks, categoryNames } = useHomePosts(); const { memberConnectionPosts, categoryLinks, categoryNames } = useHomePosts();
const [featuredMembers, setFeaturedMembers] = useState<FeaturedMemberState>({ const [featuredMembers, setFeaturedMembers] = useState<FeaturedMemberState>({
...@@ -123,29 +131,58 @@ function Members() { ...@@ -123,29 +131,58 @@ function Members() {
spaceBetween={16} spaceBetween={16}
className="w-full" className="w-full"
> >
{displayMembers.map((member, index) => ( {displayMembers.map((member, index) => {
<SwiperSlide const detailUrl = getMemberDetailUrl(member.org_link);
key={member.id} return (
className="!h-auto !w-full md:!w-[calc(50%-8px)] xl:!w-[calc(33.333%-10.67px)]" <SwiperSlide
> key={member.id}
<article className="rounded-[20px] bg-white p-[7px] shadow-[0_10px_22px_rgba(158,114,0,0.16)]"> className="!h-auto !w-full md:!w-[calc(50%-8px)] xl:!w-[calc(33.333%-10.67px)]"
<div className="flex h-[210px] items-center justify-center overflow-hidden rounded-[14px] bg-white px-4 py-5"> >
<div className="flex h-full w-full max-w-[260px] items-center justify-center"> <article className="rounded-[20px] bg-white p-[7px] shadow-[0_10px_22px_rgba(158,114,0,0.16)]">
<ImageNext {detailUrl ? (
src={resolveMemberImage(member.avatar, index)} <a
alt={member.name} href={detailUrl}
width={260} target="_blank"
height={180} rel="noreferrer"
className="h-[180px] w-[260px] max-w-full object-contain" className="block"
/> >
</div> <div className="flex h-[210px] items-center justify-center overflow-hidden rounded-[14px] bg-white px-4 py-5">
</div> <div className="flex h-full w-full max-w-[260px] items-center justify-center">
<h3 className="mt-3 line-clamp-2 min-h-[40px] px-1 text-center text-sm font-semibold leading-5 text-[#1e2f5e]"> <ImageNext
{member.name} src={resolveMemberImage(member.avatar, index)}
</h3> alt={member.name}
</article> width={260}
</SwiperSlide> height={180}
))} className="h-[180px] w-[260px] max-w-full object-contain"
/>
</div>
</div>
<h3 className="mt-3 line-clamp-2 min-h-[40px] px-1 text-center text-sm font-semibold leading-5 text-[#1e2f5e] transition-colors hover:text-[#20449a]">
{member.name}
</h3>
</a>
) : (
<>
<div className="flex h-[210px] items-center justify-center overflow-hidden rounded-[14px] bg-white px-4 py-5">
<div className="flex h-full w-full max-w-[260px] items-center justify-center">
<ImageNext
src={resolveMemberImage(member.avatar, index)}
alt={member.name}
width={260}
height={180}
className="h-[180px] w-[260px] max-w-full object-contain"
/>
</div>
</div>
<h3 className="mt-3 line-clamp-2 min-h-[40px] px-1 text-center text-sm font-semibold leading-5 text-[#1e2f5e]">
{member.name}
</h3>
</>
)}
</article>
</SwiperSlide>
);
})}
</Swiper> </Swiper>
); );
}; };
......
...@@ -23,6 +23,7 @@ export function useAdvertisements( ...@@ -23,6 +23,7 @@ export function useAdvertisements(
limit: effectiveLimit, limit: effectiveLimit,
}); });
// Backend trả về { responseData: [...] }
return ( return (
(data as unknown as { responseData?: Advertisement[] } | undefined)?.responseData ?? [] (data as unknown as { responseData?: Advertisement[] } | undefined)?.responseData ?? []
); );
......
...@@ -40,6 +40,7 @@ type RawHomePost = { ...@@ -40,6 +40,7 @@ type RawHomePost = {
is_active?: boolean | null; is_active?: boolean | null;
status?: string | null; status?: string | null;
type?: string | null; type?: string | null;
event_dates?: string[] | null;
categories?: RawHomeCategory[] | null; categories?: RawHomeCategory[] | null;
thumbnail?: RawHomeThumbnail | null; thumbnail?: RawHomeThumbnail | null;
content_structure?: { content_structure?: {
...@@ -84,6 +85,7 @@ export type HomePostItem = { ...@@ -84,6 +85,7 @@ export type HomePostItem = {
isActive: boolean; isActive: boolean;
status: string; status: string;
type: string; type: string;
eventDates?: string[];
categories: HomePostCategory[]; categories: HomePostCategory[];
thumbnail: { thumbnail: {
url: string; url: string;
...@@ -416,6 +418,9 @@ async function fetchHomePostsFromApi() { ...@@ -416,6 +418,9 @@ async function fetchHomePostsFromApi() {
isActive: item.is_active !== false, isActive: item.is_active !== false,
status: String(item.status ?? ""), status: String(item.status ?? ""),
type: String(item.type ?? ""), type: String(item.type ?? ""),
eventDates: Array.isArray(item.event_dates)
? item.event_dates.filter((d): d is string => typeof d === "string")
: [],
categories, categories,
thumbnail: thumbnailPath thumbnail: thumbnailPath
? { ? {
...@@ -642,6 +647,9 @@ export function useEventCalendarPosts(currentMonth: Date) { ...@@ -642,6 +647,9 @@ export function useEventCalendarPosts(currentMonth: Date) {
isActive: item.is_active !== false, isActive: item.is_active !== false,
status: String(item.status ?? ""), status: String(item.status ?? ""),
type: String(item.type ?? ""), type: String(item.type ?? ""),
eventDates: Array.isArray(item.event_dates)
? item.event_dates.filter((d): d is string => typeof d === "string")
: [],
categories, categories,
thumbnail: thumbnailPath thumbnail: thumbnailPath
? { ? {
...@@ -652,15 +660,16 @@ export function useEventCalendarPosts(currentMonth: Date) { ...@@ -652,15 +660,16 @@ export function useEventCalendarPosts(currentMonth: Date) {
} satisfies HomePostItem; } satisfies HomePostItem;
}); });
// Filter posts that have at least one date (startedAt, endedAt, or registrationDeadline) // Filter posts that have at least one date (eventDates, startedAt,
// falling within the current month // endedAt, or registrationDeadline) falling within the current month
return mappedPosts.filter((item) => { return mappedPosts.filter((item) => {
const startedAt = item.startedAt ? dayjs(item.startedAt) : null; const startedAt = item.startedAt ? dayjs(item.startedAt) : null;
const endedAt = item.endedAt ? dayjs(item.endedAt) : null; const endedAt = item.endedAt ? dayjs(item.endedAt) : null;
const registrationDeadline = item.registrationDeadline ? dayjs(item.registrationDeadline) : null; const registrationDeadline = item.registrationDeadline ? dayjs(item.registrationDeadline) : null;
const eventDates = (item.eventDates ?? []).map((d) => dayjs(d)).filter((d) => d.isValid());
// If no dates at all, exclude // If no dates at all, exclude
if (!startedAt && !endedAt && !registrationDeadline) return false; if (!startedAt && !endedAt && !registrationDeadline && eventDates.length === 0) return false;
const monthStartDay = dayjs(monthStart); const monthStartDay = dayjs(monthStart);
const monthEndDay = dayjs(monthEnd); const monthEndDay = dayjs(monthEnd);
...@@ -670,6 +679,13 @@ export function useEventCalendarPosts(currentMonth: Date) { ...@@ -670,6 +679,13 @@ export function useEventCalendarPosts(currentMonth: Date) {
return date !== null && !date.isBefore(monthStartDay, "day") && !date.isAfter(monthEndDay, "day"); return date !== null && !date.isBefore(monthStartDay, "day") && !date.isAfter(monthEndDay, "day");
}; };
// If specific event_dates are set, only check those (they take
// priority over the started_at/ended_at range — same rule the
// calendar rendering uses in getEventDateRange).
if (eventDates.length > 0) {
return eventDates.some((d) => hasDateInMonth(d));
}
// Check if event overlaps with current month (spans across the month) // Check if event overlaps with current month (spans across the month)
const eventStartDate = startedAt || registrationDeadline; const eventStartDate = startedAt || registrationDeadline;
const eventEndDate = endedAt || registrationDeadline || startedAt; const eventEndDate = endedAt || registrationDeadline || startedAt;
......
...@@ -9,6 +9,7 @@ import type { ...@@ -9,6 +9,7 @@ import type {
DynamicPostContentSection, DynamicPostContentSection,
DynamicPostItem, DynamicPostItem,
DynamicPostThumbnail, DynamicPostThumbnail,
DynamicPostUser,
} from "./types"; } from "./types";
type CategoryListResponse = { type CategoryListResponse = {
...@@ -31,6 +32,16 @@ type RawPostThumbnail = { ...@@ -31,6 +32,16 @@ type RawPostThumbnail = {
url?: string | null; url?: string | null;
}; };
type RawPostUser = {
id?: string | null;
email?: string | null;
username?: string | null;
first_name?: string | null;
last_name?: string | null;
full_name?: string | null;
avatar_url?: string | null;
} | null;
type RawPostSectionImage = { type RawPostSectionImage = {
position?: number | null; position?: number | null;
image?: { image?: {
...@@ -66,6 +77,8 @@ type RawPostItem = { ...@@ -66,6 +77,8 @@ type RawPostItem = {
type?: string | null; type?: string | null;
thumbnail?: RawPostThumbnail | null; thumbnail?: RawPostThumbnail | null;
categories?: RawPostCategory[] | null; categories?: RawPostCategory[] | null;
creator?: RawPostUser;
editor?: RawPostUser;
content_structure?: { content_structure?: {
post_content?: Array<{ post_content?: Array<{
id?: string | null; id?: string | null;
...@@ -205,6 +218,26 @@ const mapPostContentSections = (item: RawPostItem): DynamicPostContentSection[] ...@@ -205,6 +218,26 @@ const mapPostContentSections = (item: RawPostItem): DynamicPostContentSection[]
})); }));
}; };
const mapPostUser = (user: RawPostUser | undefined): DynamicPostUser => {
if (!user || typeof user !== "object") return null;
const firstName = String(user.first_name ?? "").trim();
const lastName = String(user.last_name ?? "").trim();
const fullName =
String(user.full_name ?? "").trim() ||
[firstName, lastName].filter(Boolean).join(" ").trim() ||
String(user.username ?? "").trim() ||
String(user.email ?? "").trim();
return {
id: String(user.id ?? ""),
email: String(user.email ?? ""),
username: user.username ? String(user.username) : null,
first_name: firstName || null,
last_name: lastName || null,
full_name: fullName,
avatar_url: user.avatar_url ? String(user.avatar_url) : null,
};
};
const mapPost = (item: RawPostItem): DynamicPostItem => ({ const mapPost = (item: RawPostItem): DynamicPostItem => ({
id: String(item.id ?? ""), id: String(item.id ?? ""),
title: String(item.title ?? "").trim(), title: String(item.title ?? "").trim(),
...@@ -239,6 +272,8 @@ const mapPost = (item: RawPostItem): DynamicPostItem => ({ ...@@ -239,6 +272,8 @@ const mapPost = (item: RawPostItem): DynamicPostItem => ({
content_structure: { content_structure: {
post_content: mapPostContentSections(item), post_content: mapPostContentSections(item),
}, },
creator: mapPostUser(item.creator),
editor: mapPostUser(item.editor),
}); });
const buildPostFilters = (filters: Array<string | null | undefined>) => const buildPostFilters = (filters: Array<string | null | undefined>) =>
......
...@@ -30,6 +30,16 @@ export type DynamicPostThumbnail = { ...@@ -30,6 +30,16 @@ export type DynamicPostThumbnail = {
url?: string | null; url?: string | null;
} | null; } | null;
export type DynamicPostUser = {
id: string;
email: string;
username: string | null;
first_name: string | null;
last_name: string | null;
full_name: string;
avatar_url: string | null;
} | null;
export type DynamicPostContentSection = { export type DynamicPostContentSection = {
id: string; id: string;
type: string; type: string;
...@@ -76,4 +86,6 @@ export type DynamicPostItem = { ...@@ -76,4 +86,6 @@ export type DynamicPostItem = {
content_structure: { content_structure: {
post_content: DynamicPostContentSection[]; post_content: DynamicPostContentSection[];
} | null; } | null;
creator: DynamicPostUser;
editor: DynamicPostUser;
}; };
...@@ -47,6 +47,7 @@ import { ...@@ -47,6 +47,7 @@ import {
fetchCmsNewsItems, fetchCmsNewsItems,
fetchCmsPostCount, fetchCmsPostCount,
fetchHeaderConfigItems, fetchHeaderConfigItems,
toggleCmsNewsVisibility,
} from "@/lib/api/cms-admin"; } from "@/lib/api/cms-admin";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { import {
...@@ -239,6 +240,7 @@ export default function AdminNewsPage() { ...@@ -239,6 +240,7 @@ export default function AdminNewsPage() {
const [deleteTarget, setDeleteTarget] = React.useState<AdminNewsItem | null>(null); const [deleteTarget, setDeleteTarget] = React.useState<AdminNewsItem | null>(null);
const [ready, setReady] = React.useState(false); const [ready, setReady] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false); const [isDeleting, setIsDeleting] = React.useState(false);
const [togglingVisibilityId, setTogglingVisibilityId] = React.useState<string | null>(null);
const [page, setPage] = React.useState(() => { const [page, setPage] = React.useState(() => {
const parsedPage = Number(searchParams.get("page") ?? 1); const parsedPage = Number(searchParams.get("page") ?? 1);
return Number.isFinite(parsedPage) && parsedPage > 0 ? Math.floor(parsedPage) : 1; return Number.isFinite(parsedPage) && parsedPage > 0 ? Math.floor(parsedPage) : 1;
...@@ -450,6 +452,25 @@ export default function AdminNewsPage() { ...@@ -450,6 +452,25 @@ export default function AdminNewsPage() {
} }
}; };
const handleToggleVisibility = async (item: AdminNewsItem) => {
if (togglingVisibilityId) return;
const nextIsHidden = !item.is_hidden;
setTogglingVisibilityId(item.id);
try {
await toggleCmsNewsVisibility(item.id, nextIsHidden);
toast.success(nextIsHidden ? "Đã ẩn bài viết" : "Đã hiển thị bài viết");
await Promise.all([load(), loadStats()]);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Không thể thay đổi trạng thái hiển thị",
);
} finally {
setTogglingVisibilityId(null);
}
};
const totalPages = Math.ceil(total / pageSize); const totalPages = Math.ceil(total / pageSize);
const handlePageChange = (newPage: number) => { const handlePageChange = (newPage: number) => {
...@@ -528,17 +549,17 @@ export default function AdminNewsPage() { ...@@ -528,17 +549,17 @@ export default function AdminNewsPage() {
<TableHead className="w-[140px] py-4 text-center text-white"> <TableHead className="w-[140px] py-4 text-center text-white">
Hình ảnh đại diện Hình ảnh đại diện
</TableHead> </TableHead>
<TableHead className="w-40 py-4 text-center text-white"> <TableHead className="w-[220px] py-4 text-center text-white">
Loại bài viết Loại / Danh mục
</TableHead>
<TableHead className="w-[190px] py-4 text-center text-white">
Danh mục hiển thị
</TableHead> </TableHead>
<TableHead className="w-[170px] py-4 text-center text-white"> <TableHead className="w-[170px] py-4 text-center text-white">
Ngày xuất bản Ngày xuất bản / Hết hạn
</TableHead> </TableHead>
<TableHead className="w-[170px] py-4 text-center text-white"> <TableHead className="w-[150px] py-4 text-center text-white">
Ngày hết hạn Người tạo
</TableHead>
<TableHead className="w-[150px] py-4 text-center text-white">
Cập nhật bởi
</TableHead> </TableHead>
<TableHead className="w-[130px] py-4 text-center text-white"> <TableHead className="w-[130px] py-4 text-center text-white">
Thao tác Thao tác
...@@ -598,31 +619,64 @@ export default function AdminNewsPage() { ...@@ -598,31 +619,64 @@ export default function AdminNewsPage() {
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]"> <div className="flex flex-col items-center gap-1">
{ADMIN_NEWS_TYPE_LABELS[item.type]} <Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
</Badge> {ADMIN_NEWS_TYPE_LABELS[item.type]}
</Badge>
<span className="text-sm text-gray-700">
{primaryCategoryName}
{extraCategoryCount > 0 ? ` (+${extraCategoryCount})` : ""}
</span>
</div>
</TableCell> </TableCell>
<TableCell className="text-center text-sm text-gray-700"> <TableCell className="text-center text-sm text-gray-700">
{primaryCategoryName} <div className="flex flex-col gap-0.5">
{extraCategoryCount > 0 ? ` (+${extraCategoryCount})` : ""} <span>{formatDateTime(item.published_at) || "—"}</span>
<span className="text-gray-500">{formatDateTime(item.expired_at) || "—"}</span>
</div>
</TableCell> </TableCell>
<TableCell className="text-center text-sm text-gray-700"> <TableCell className="text-center text-sm text-gray-700">
{formatDateTime(item.published_at)} {item.creator ? (
<div className="flex flex-col gap-0.5">
<span className="font-medium text-[#1f3768]">
{item.creator.full_name}
</span>
<span className="text-gray-500">
{formatDateTime(item.created_at) || "—"}
</span>
</div>
) : (
<span className="text-gray-400"></span>
)}
</TableCell> </TableCell>
<TableCell className="text-center text-sm text-gray-700"> <TableCell className="text-center text-sm text-gray-700">
{formatDateTime(item.expired_at)} {item.editor && item.editor.id ? (
<div className="flex flex-col gap-0.5">
<span className="font-medium text-[#1f3768]">
{item.editor.full_name}
</span>
<span className="text-gray-500">
{formatDateTime(item.updated_at) || "—"}
</span>
</div>
) : (
<span className="text-gray-400"></span>
)}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<AdminRowActions <AdminRowActions
actions={[ actions={[
{ {
kind: item.is_hidden ? "hidden" : "visible", kind: item.is_hidden ? "hidden" : "visible",
label: item.is_hidden label: item.is_hidden
? "B\u00e0i vi\u1ebft \u0111ang \u1ea9n" ? "B\u00e0i vi\u1ebft \u0111ang \u1ea9n, b\u1ea5m \u0111\u1ec3 hi\u1ec3n th\u1ecb"
: "B\u00e0i vi\u1ebft \u0111ang hi\u1ec3n th\u1ecb", : "B\u00e0i vi\u1ebft \u0111ang hi\u1ec3n th\u1ecb, b\u1ea5m \u0111\u1ec3 \u1ea9n",
disabled: togglingVisibilityId === item.id,
onClick: () => void handleToggleVisibility(item),
}, },
{ {
kind: "edit", kind: "edit",
......
...@@ -71,6 +71,7 @@ import { ...@@ -71,6 +71,7 @@ import {
usePatchApiV10UserIdStatus, usePatchApiV10UserIdStatus,
usePostApiV10UserIdResetPassword, usePostApiV10UserIdResetPassword,
usePostApiV10UserIdRole, usePostApiV10UserIdRole,
useDeleteApiV10UserIdRole,
useGetApiV10UserId, useGetApiV10UserId,
usePutApiV10UserId, usePutApiV10UserId,
putApiV10UserId, putApiV10UserId,
...@@ -234,6 +235,7 @@ export default function UsersPage() { ...@@ -234,6 +235,7 @@ export default function UsersPage() {
const toggleStatusMutation = usePatchApiV10UserIdStatus(); const toggleStatusMutation = usePatchApiV10UserIdStatus();
const resetPasswordMutation = usePostApiV10UserIdResetPassword(); const resetPasswordMutation = usePostApiV10UserIdResetPassword();
const assignRoleMutation = usePostApiV10UserIdRole(); const assignRoleMutation = usePostApiV10UserIdRole();
const removeRoleMutation = useDeleteApiV10UserIdRole();
// Data // Data
const roles: Role[] = ((rolesData as unknown as { responseData?: { rows?: Role[] } })?.responseData?.rows) || []; const roles: Role[] = ((rolesData as unknown as { responseData?: { rows?: Role[] } })?.responseData?.rows) || [];
...@@ -373,16 +375,64 @@ export default function UsersPage() { ...@@ -373,16 +375,64 @@ export default function UsersPage() {
} }
try { try {
const primaryRole = roles.find((r) => r.name === userRoles[0]); // Compute diff between current roles (from selectedUser) and new
if (primaryRole) { // selection (userRoles). Both arrays hold role *names*.
const currentRoleNames = selectedUser.roles ?? [];
const currentRoleSet = new Set(currentRoleNames);
const newRoleSet = new Set(userRoles);
// Roles to remove: in current but not in new
const rolesToRemove = currentRoleNames.filter(
(name) => !newRoleSet.has(name),
);
// Roles to add: in new but not in current
const rolesToAdd = userRoles.filter(
(name) => !currentRoleSet.has(name),
);
// Resolve role IDs from name
const roleByName = new Map(roles.map((r) => [r.name, r]));
// 1. Remove roles no longer selected
for (const roleName of rolesToRemove) {
const role = roleByName.get(roleName);
if (!role) continue;
await removeRoleMutation.mutateAsync({
id: selectedUser.id,
data: { role_id: role.id },
});
}
// 2. Add new roles. userRoles[0] is treated as primary.
// Non-primary roles get is_primary=false.
for (let i = 0; i < rolesToAdd.length; i++) {
const role = roleByName.get(rolesToAdd[i]);
if (!role) continue;
await assignRoleMutation.mutateAsync({ await assignRoleMutation.mutateAsync({
id: selectedUser.id, id: selectedUser.id,
data: { data: {
role_id: primaryRole.id, role_id: role.id,
is_primary: true, is_primary: i === 0,
}, },
}); });
} }
// 3. If no roles were added but primary changed (e.g. user reordered
// existing roles), still POST the first selected role with
// is_primary=true. Backend will update is_primary without 409.
if (rolesToAdd.length === 0 && rolesToRemove.length === 0) {
const primaryRole = roleByName.get(userRoles[0]);
if (primaryRole) {
await assignRoleMutation.mutateAsync({
id: selectedUser.id,
data: {
role_id: primaryRole.id,
is_primary: true,
},
});
}
}
toast.success("Cập nhật vai trò thành công!"); toast.success("Cập nhật vai trò thành công!");
setIsRoleDialogOpen(false); setIsRoleDialogOpen(false);
queryClient.invalidateQueries({ queryKey: ["/api/v1.0/user"], exact: false }); queryClient.invalidateQueries({ queryKey: ["/api/v1.0/user"], exact: false });
...@@ -978,7 +1028,7 @@ export default function UsersPage() { ...@@ -978,7 +1028,7 @@ export default function UsersPage() {
</Button> </Button>
<Button <Button
onClick={handleSaveRoles} onClick={handleSaveRoles}
disabled={userRoles.length === 0 || assignRoleMutation.isPending} disabled={userRoles.length === 0 || assignRoleMutation.isPending || removeRoleMutation.isPending}
className="h-10 rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90" className="h-10 rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
> >
{assignRoleMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {assignRoleMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
......
...@@ -4,6 +4,12 @@ import { MOCK_FEATURED_MEMBERS_RESPONSE } from "@/app/api/mock-data"; ...@@ -4,6 +4,12 @@ import { MOCK_FEATURED_MEMBERS_RESPONSE } from "@/app/api/mock-data";
const BACKEND_HOST = process.env.NEXT_PUBLIC_BACKEND_HOST || "https://news.vccihcm.vn"; const BACKEND_HOST = process.env.NEXT_PUBLIC_BACKEND_HOST || "https://news.vccihcm.vn";
const FEATURED_MEMBER_API_URL = `${BACKEND_HOST.replace(/\/+$/, "")}/api/v1.0/vcci/featured-members`; const FEATURED_MEMBER_API_URL = `${BACKEND_HOST.replace(/\/+$/, "")}/api/v1.0/vcci/featured-members`;
// Fallback: gọi trực tiếp VCCI HCM API
const FALLBACK_FEATURED_MEMBER_API_URL =
"https://vccihcm.vn/api/v1.0/organizations" +
"?filters=users.status_id+%3D%3D+36ca1cc5-7b6e-4f9f-b973-69c5207deb62" +
"&pageSize=12&sortField=created_at&sortOrder=ASC";
export async function GET() { export async function GET() {
try { try {
const response = await fetch(FEATURED_MEMBER_API_URL, { const response = await fetch(FEATURED_MEMBER_API_URL, {
...@@ -12,17 +18,40 @@ export async function GET() { ...@@ -12,17 +18,40 @@ export async function GET() {
}); });
if (!response.ok) { if (!response.ok) {
// Khi upstream lỗi, vẫn trả mock để FE không hiển thị "Chưa có thông tin".
console.warn( console.warn(
`[api/featured-members] upstream returned ${response.status}, serving mock data`, `[api/featured-members] upstream returned ${response.status}, trying fallback`,
); );
return NextResponse.json(MOCK_FEATURED_MEMBERS_RESPONSE, { status: 200 }); // Thử fallback - gọi trực tiếp VCCI HCM API
return tryFallback();
} }
const data = await response.json(); const data = await response.json();
return NextResponse.json(data); return NextResponse.json(data);
} catch (error) { } catch (error) {
console.error("[api/featured-members] upstream failed, serving mock data", error); console.error("[api/featured-members] upstream failed, trying fallback", error);
return tryFallback();
}
}
async function tryFallback() {
try {
const fallbackResponse = await fetch(FALLBACK_FEATURED_MEMBER_API_URL, {
headers: { Accept: "application/json" },
next: { revalidate: 300 },
});
if (!fallbackResponse.ok) {
console.warn(
`[api/featured-members] fallback returned ${fallbackResponse.status}, serving mock data`,
);
return NextResponse.json(MOCK_FEATURED_MEMBERS_RESPONSE, { status: 200 });
}
const data = await fallbackResponse.json();
console.log("[api/featured-members] served from fallback");
return NextResponse.json(data);
} catch (error) {
console.error("[api/featured-members] fallback failed, serving mock data", error);
return NextResponse.json(MOCK_FEATURED_MEMBERS_RESPONSE, { status: 200 }); return NextResponse.json(MOCK_FEATURED_MEMBERS_RESPONSE, { status: 200 });
} }
} }
...@@ -4,6 +4,12 @@ import { MOCK_PARTNERS_RESPONSE } from "@/app/api/mock-data"; ...@@ -4,6 +4,12 @@ import { MOCK_PARTNERS_RESPONSE } from "@/app/api/mock-data";
const BACKEND_HOST = process.env.NEXT_PUBLIC_BACKEND_HOST || "https://news.vccihcm.vn"; const BACKEND_HOST = process.env.NEXT_PUBLIC_BACKEND_HOST || "https://news.vccihcm.vn";
const PARTNER_API_URL = `${BACKEND_HOST.replace(/\/+$/, "")}/api/v1.0/vcci/partners`; const PARTNER_API_URL = `${BACKEND_HOST.replace(/\/+$/, "")}/api/v1.0/vcci/partners`;
// Fallback: gọi trực tiếp VCCI HCM API
const FALLBACK_PARTNER_API_URL =
"https://vccihcm.vn/api/v1.0/organizations" +
"?filters=type%3D%3DSPONSOR&pageSize=12" +
"&sortField=sort_order&sortOrder=ASC";
export async function GET() { export async function GET() {
try { try {
const response = await fetch(PARTNER_API_URL, { const response = await fetch(PARTNER_API_URL, {
...@@ -12,17 +18,40 @@ export async function GET() { ...@@ -12,17 +18,40 @@ export async function GET() {
}); });
if (!response.ok) { if (!response.ok) {
// Khi upstream lỗi, vẫn trả mock để FE không hiển thị "Chưa có thông tin".
console.warn( console.warn(
`[api/partners] upstream returned ${response.status}, serving mock data`, `[api/partners] upstream returned ${response.status}, trying fallback`,
); );
return NextResponse.json(MOCK_PARTNERS_RESPONSE, { status: 200 }); // Thử fallback - gọi trực tiếp VCCI HCM API
return tryFallback();
} }
const data = await response.json(); const data = await response.json();
return NextResponse.json(data); return NextResponse.json(data);
} catch (error) { } catch (error) {
console.error("[api/partners] upstream failed, serving mock data", error); console.error("[api/partners] upstream failed, trying fallback", error);
return tryFallback();
}
}
async function tryFallback() {
try {
const fallbackResponse = await fetch(FALLBACK_PARTNER_API_URL, {
headers: { Accept: "application/json" },
next: { revalidate: 300 },
});
if (!fallbackResponse.ok) {
console.warn(
`[api/partners] fallback returned ${fallbackResponse.status}, serving mock data`,
);
return NextResponse.json(MOCK_PARTNERS_RESPONSE, { status: 200 });
}
const data = await fallbackResponse.json();
console.log("[api/partners] served from fallback");
return NextResponse.json(data);
} catch (error) {
console.error("[api/partners] fallback failed, serving mock data", error);
return NextResponse.json(MOCK_PARTNERS_RESPONSE, { status: 200 }); return NextResponse.json(MOCK_PARTNERS_RESPONSE, { status: 200 });
} }
} }
...@@ -2,15 +2,18 @@ ...@@ -2,15 +2,18 @@
import * as React from "react"; import * as React from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ArrowLeft, Check, ChevronsUpDown, Save, Upload, X } from "lucide-react"; import { ArrowLeft, Check, ChevronsUpDown, Save, Upload, X, Calendar as CalendarIcon, Plus } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminImagePicker } from "@/components/admin/image-picker"; import { AdminImagePicker } from "@/components/admin/image-picker";
import { AdminPostContentEditor } from "@/components/admin/post-content-editor"; import { AdminPostContentEditor } from "@/components/admin/post-content-editor";
import { PostHistoryViewer } from "@/components/admin/post-history-viewer";
import { AdminRichTextEditor } from "@/components/admin/rich-text-editor"; import { AdminRichTextEditor } from "@/components/admin/rich-text-editor";
import { SafeNextImage } from "@/components/admin/safe-next-image"; import { SafeNextImage } from "@/components/admin/safe-next-image";
import { PermissionGate } from "@/components/shared/permission-gate";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { import {
Command, Command,
...@@ -399,6 +402,10 @@ export function AdminNewsForm({ ...@@ -399,6 +402,10 @@ export function AdminNewsForm({
const [isSubmitting, setIsSubmitting] = React.useState(false); const [isSubmitting, setIsSubmitting] = React.useState(false);
const [isLoadingInitialData, setIsLoadingInitialData] = React.useState(true); const [isLoadingInitialData, setIsLoadingInitialData] = React.useState(true);
const [isMissingPost, setIsMissingPost] = React.useState(false); const [isMissingPost, setIsMissingPost] = React.useState(false);
// Toggle state for "Hiển thị các ngày cụ thể trên lịch" — independent of
// whether event_dates array is empty, so the date picker section stays
// visible after enabling (even before any dates are added).
const [useEventDates, setUseEventDates] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -440,6 +447,7 @@ export function AdminNewsForm({ ...@@ -440,6 +447,7 @@ export function AdminNewsForm({
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
setUseEventDates(false);
return; return;
} }
...@@ -483,6 +491,8 @@ export function AdminNewsForm({ ...@@ -483,6 +491,8 @@ export function AdminNewsForm({
nextForm.header_category_id = nextForm.category_ids[0] ?? ""; nextForm.header_category_id = nextForm.category_ids[0] ?? "";
} }
setForm(nextForm); setForm(nextForm);
// Enable the "specific dates" toggle if the post already has event_dates
setUseEventDates((nextForm.event_dates ?? []).length > 0);
} catch (error) { } catch (error) {
if (cancelled) return; if (cancelled) return;
toast.error(error instanceof Error ? error.message : "Không thể tải bài viết"); toast.error(error instanceof Error ? error.message : "Không thể tải bài viết");
...@@ -735,6 +745,7 @@ export function AdminNewsForm({ ...@@ -735,6 +745,7 @@ export function AdminNewsForm({
registration_deadline: form.registration_deadline || null, registration_deadline: form.registration_deadline || null,
location: form.location.trim(), location: form.location.trim(),
participation_fee: form.participation_fee.trim(), participation_fee: form.participation_fee.trim(),
event_dates: (form.event_dates ?? []).length > 0 ? form.event_dates : null,
post_content: form.post_content.map((section, index) => ({ post_content: form.post_content.map((section, index) => ({
...section, ...section,
position: index + 1, position: index + 1,
...@@ -1089,7 +1100,65 @@ export function AdminNewsForm({ ...@@ -1089,7 +1100,65 @@ export function AdminNewsForm({
title="Thông tin sự kiện (tùy chọn)" title="Thông tin sự kiện (tùy chọn)"
description="Nhóm các trường dành cho bài viết có tính chất sự kiện hoặc chương trình." description="Nhóm các trường dành cho bài viết có tính chất sự kiện hoặc chương trình."
> >
<div className="rounded-xl border border-[#063e8e]/15 p-4"> <div className="rounded-xl border border-[#063e8e]/15 p-4 space-y-4">
{/* Toggle: Sử dụng ngày cụ thể */}
<div className="flex items-center gap-3 rounded-lg border border-[#063e8e]/10 bg-[#063e8e]/5 p-3">
<Checkbox
id="use-event-dates"
checked={useEventDates}
onCheckedChange={(checked) => {
setUseEventDates(checked === true);
if (!checked) {
// Tắt: xóa hết event_dates
handleField("event_dates", []);
}
}}
className="border-[#063e8e]/30 data-[state=checked]:border-[#063e8e] data-[state=checked]:bg-[#063e8e]"
/>
<div className="flex-1">
<Label htmlFor="use-event-dates" className="cursor-pointer text-sm font-medium text-gray-700">
Hiển thị các ngày cụ thể trên lịch
</Label>
<p className="text-xs text-gray-500">
Thay vì hiển thị tất cả ngày từ bắt đầu đến kết thúc, chỉ hiển thị những ngày bạn chọn bên dưới
</p>
</div>
</div>
{/* Ngày cụ thể - chỉ hiển thị khi được bật */}
{useEventDates && (
<div className="space-y-2">
<Label className="block text-sm font-medium text-gray-700">
Các ngày cụ thể ({(form.event_dates ?? []).length} ngày)
</Label>
<div className="flex flex-wrap gap-2">
{(form.event_dates ?? []).map((date, index) => (
<div
key={date}
className="flex items-center gap-1 rounded-lg bg-[#063e8e]/10 px-3 py-1.5 text-sm text-[#063e8e]"
>
<CalendarIcon className="h-3.5 w-3.5" />
<span>{dayjs(date).format("DD/MM/YYYY")}</span>
<button
type="button"
onClick={() => {
const newDates = (form.event_dates ?? []).filter((_, i) => i !== index);
handleField("event_dates", newDates);
}}
className="ml-1 rounded-full p-0.5 hover:bg-[#063e8e]/20"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
<EventDatesDatePicker
value={form.event_dates ?? []}
onChange={(dates) => handleField("event_dates", dates)}
/>
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<div> <div>
<Label className="mb-1.5 block text-gray-700">Ngày bắt đầu</Label> <Label className="mb-1.5 block text-gray-700">Ngày bắt đầu</Label>
...@@ -1199,6 +1268,108 @@ export function AdminNewsForm({ ...@@ -1199,6 +1268,108 @@ export function AdminNewsForm({
onOpenChange={setPickerOpen} onOpenChange={setPickerOpen}
onSelect={handleThumbnailSelect} onSelect={handleThumbnailSelect}
/> />
{!isCreate && newsId && (
<PermissionGate required="posts:read">
<PostHistoryViewer postId={newsId} />
</PermissionGate>
)}
</div> </div>
); );
} }
function EventDatesDatePicker({
value,
onChange,
}: {
value: string[];
onChange: (dates: string[]) => void;
}) {
const [popoverOpen, setPopoverOpen] = React.useState(false);
// Convert stored "YYYY-MM-DD" strings to Date objects for react-day-picker
const selectedDates = React.useMemo(
() => value.map((d) => dayjs(d).toDate()).filter((d) => !Number.isNaN(d.getTime())),
[value],
);
const handleMultipleSelect = (dates: Date[] | undefined) => {
if (!dates) {
onChange([]);
return;
}
const newDates = Array.from(
new Set(dates.map((d) => dayjs(d).format("YYYY-MM-DD"))),
).sort();
onChange(newDates);
};
return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="border-[#063e8e]/15 bg-white text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Plus className="mr-2 h-4 w-4" />
Thêm ngày{value.length > 0 ? ` (${value.length})` : ""}
</Button>
</PopoverTrigger>
<PopoverContent className="w-fit p-0" align="start">
<div className="p-4">
<div className="mb-3 flex items-center justify-between gap-2 border-b border-gray-100 pb-3">
<span className="text-base font-semibold text-[#063e8e]">
{value.length > 0
? `Đã chọn ${value.length} ngày`
: "Chọn các ngày cụ thể"}
</span>
{value.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-3 text-sm text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => onChange([])}
>
Xóa tất cả
</Button>
)}
</div>
<Calendar
mode="multiple"
selected={selectedDates}
onSelect={handleMultipleSelect}
className="w-full [--cell-size:3.5rem]"
classNames={{
root: "w-full",
month: "flex w-full flex-col gap-4",
month_caption: "flex h-12 w-full items-center justify-center px-2 text-xl font-bold text-[#063e8e]",
nav: "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
button_previous: "h-12 w-12 select-none p-0 text-[#063e8e] hover:bg-[#063e8e]/10 aria-disabled:opacity-50 [&>svg]:size-6",
button_next: "h-12 w-12 select-none p-0 text-[#063e8e] hover:bg-[#063e8e]/10 aria-disabled:opacity-50 [&>svg]:size-6",
weekday: "flex-1 select-none rounded-md text-sm font-semibold uppercase text-gray-400",
day: "group/day relative aspect-square h-full w-full select-none p-0 text-center text-lg",
today: "ring-2 ring-[#063e8e]/40 rounded-full bg-[#063e8e]/5 text-[#063e8e] font-semibold",
outside: "text-gray-300",
}}
/>
<div className="mt-3 flex items-center justify-between gap-4 border-t border-gray-100 pt-3">
<span className="text-sm text-gray-400">
Click ngày để chọn / bỏ chọn
</span>
<Button
type="button"
variant="default"
size="default"
className="bg-[#063e8e] hover:bg-[#063e8e]/90"
onClick={() => setPopoverOpen(false)}
>
Xong
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
"use client";
import * as React from "react";
import { Clock, ChevronDown, ChevronUp, User, Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import { useGetApiV10PostIdHistory } from "@/api/endpoints/post-history";
import type { PostHistoryItem, PostHistoryAction } from "@/api/types/post-history";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
const ACTION_CONFIG: Record<
PostHistoryAction,
{ label: string; icon: React.ComponentType<{ className?: string }>; className: string }
> = {
CREATE: {
label: "Tạo bài viết",
icon: Plus,
className: "bg-green-100 text-green-700 border-green-200",
},
UPDATE: {
label: "Cập nhật",
icon: Pencil,
className: "bg-blue-100 text-blue-700 border-blue-200",
},
DELETE: {
label: "Xóa bài viết",
icon: Trash2,
className: "bg-red-100 text-red-700 border-red-200",
},
};
function formatDate(dateString: string) {
const date = new Date(dateString);
return date.toLocaleString("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatFieldName(field: string): string {
const fieldMap: Record<string, string> = {
title: "Tiêu đề",
content: "Nội dung",
summary: "Tóm tắt",
status: "Trạng thái",
is_hidden: "Ẩn/Hiện",
is_featured: "Tin nổi bật",
thumbnail_id: "Hình đại diện",
slug: "Slug",
published_at: "Ngày xuất bản",
expired_at: "Ngày hết hạn",
external_link: "Liên kết ngoài",
};
return fieldMap[field] || field;
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return "(trống)";
if (typeof value === "boolean") return value ? "Có" : "Không";
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
}
function HistoryItem({ item }: { item: PostHistoryItem }) {
const config = ACTION_CONFIG[item.action];
const Icon = config.icon;
const [isOpen, setIsOpen] = React.useState(item.action === "CREATE");
return (
<div className="rounded-lg border border-gray-200 bg-white">
<div
className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50"
onClick={() => setIsOpen(!isOpen)}
>
<div className="flex items-center gap-3">
<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", config.className)}>
<Icon className="h-4 w-4" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{config.label}</span>
{item.actor && (
<span className="text-sm text-gray-500">
bởi <span className="font-medium">{item.actor.full_name || item.actor.email}</span>
</span>
)}
</div>
<p className="text-sm text-gray-500">{formatDate(item.created_at)}</p>
</div>
</div>
{isOpen ? (
<ChevronUp className="h-5 w-5 text-gray-400" />
) : (
<ChevronDown className="h-5 w-5 text-gray-400" />
)}
</div>
{isOpen && (
<div className="border-t border-gray-100 p-3">
{item.action === "UPDATE" && item.changes && Object.keys(item.changes).length > 0 ? (
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">Các thay đổi:</p>
{Object.entries(item.changes).map(([field, { old: oldVal, new: newVal }]) => (
<div key={field} className="grid grid-cols-[120px_1fr_1fr] items-center gap-2 text-sm">
<span className="font-medium text-gray-600">{formatFieldName(field)}:</span>
<div className="rounded bg-red-50 px-2 py-1 text-red-700 line-through">
{formatValue(oldVal)}
</div>
<div className="rounded bg-green-50 px-2 py-1 text-green-700">
{formatValue(newVal)}
</div>
</div>
))}
</div>
) : item.action === "DELETE" && item.snapshot ? (
<div className="space-y-1 text-sm text-gray-600">
<p className="font-medium text-gray-700">Dữ liệu bài viết trước khi xóa:</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span>Tiêu đề:</span>
<span className="font-medium">{String(item.snapshot.title || "")}</span>
<span>Trạng thái:</span>
<span className="font-medium">{String(item.snapshot.status || "")}</span>
</div>
</div>
) : item.snapshot ? (
<div className="space-y-1 text-sm text-gray-600">
<p className="font-medium text-gray-700">Trạng thái ban đầu:</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span>Tiêu đề:</span>
<span className="font-medium">{String(item.snapshot.title || item.snapshot.name || "")}</span>
<span>Trạng thái:</span>
<span className="font-medium">{String(item.snapshot.status || "")}</span>
</div>
</div>
) : (
<p className="text-sm text-gray-500 italic">Không có chi tiết</p>
)}
</div>
)}
</div>
);
}
function HistoryLoadingState() {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-[#063e8e]" />
<span className="ml-2 text-gray-600">Đang tải lịch sử...</span>
</div>
);
}
function HistoryEmptyState() {
return (
<div className="py-8 text-center text-gray-500">
<Clock className="mx-auto mb-2 h-8 w-8 text-gray-300" />
<p>Chưa có lịch sử chỉnh sửa</p>
</div>
);
}
function HistoryErrorState({ error }: { error: string }) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-700">
<p>Không thể tải lịch sử: {error}</p>
</div>
);
}
interface PostHistoryViewerProps {
postId: string;
}
export function PostHistoryViewer({ postId }: PostHistoryViewerProps) {
const [isOpen, setIsOpen] = React.useState(false);
const { data, isLoading, isError, error } = useGetApiV10PostIdHistory(postId, {
query: {
enabled: isOpen,
},
});
const historyItems = React.useMemo(() => {
// useQuery's `data` IS the API response body: { responseData: [...], ... }
const raw = data as unknown as { responseData?: PostHistoryItem[]; data?: PostHistoryItem[] } | undefined;
if (!raw) return [];
return raw.responseData ?? raw.data ?? [];
}, [data]);
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-xl border border-[#063e8e]/15 bg-white p-4 text-left transition hover:bg-[#063e8e]/5"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#063e8e]/10">
<Clock className="h-5 w-5 text-[#063e8e]" />
</div>
<div>
<p className="font-semibold text-[#063e8e]">Lịch sử chỉnh sửa</p>
<p className="text-sm text-gray-500">
{isLoading ? "Đang tải..." : `${historyItems.length} thay đổi`}
</p>
</div>
</div>
{isOpen ? (
<ChevronUp className="h-5 w-5 text-[#063e8e]" />
) : (
<ChevronDown className="h-5 w-5 text-[#063e8e]" />
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 space-y-2">
{isLoading ? (
<HistoryLoadingState />
) : isError ? (
<HistoryErrorState error={error?.message || "Lỗi không xác định"} />
) : historyItems.length === 0 ? (
<HistoryEmptyState />
) : (
historyItems.map((item) => <HistoryItem key={item.id} item={item} />)
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
...@@ -13,7 +13,6 @@ const FALLBACK_HREF = "https://vcci-hcm.org.vn"; ...@@ -13,7 +13,6 @@ const FALLBACK_HREF = "https://vcci-hcm.org.vn";
function SidebarAdItem({ item }: { item: Advertisement }) { function SidebarAdItem({ item }: { item: Advertisement }) {
const initialSrc = item.file?.path ? resolveUploadUrl(item.file.path) : FALLBACK_SRC; const initialSrc = item.file?.path ? resolveUploadUrl(item.file.path) : FALLBACK_SRC;
const [src, setSrc] = useState(initialSrc); const [src, setSrc] = useState(initialSrc);
const isGif = src.toLowerCase().endsWith(".gif");
return ( return (
<Link <Link
...@@ -29,7 +28,7 @@ function SidebarAdItem({ item }: { item: Advertisement }) { ...@@ -29,7 +28,7 @@ function SidebarAdItem({ item }: { item: Advertisement }) {
alt={item.alt || item.name} alt={item.alt || item.name}
fill fill
className="h-full w-full object-cover" className="h-full w-full object-cover"
unoptimized={isGif} unoptimized
onError={() => { onError={() => {
if (src !== FALLBACK_SRC) setSrc(FALLBACK_SRC); if (src !== FALLBACK_SRC) setSrc(FALLBACK_SRC);
}} }}
...@@ -54,6 +53,7 @@ function FallbackSidebarAdItem() { ...@@ -54,6 +53,7 @@ function FallbackSidebarAdItem() {
alt="Quảng cáo VCCI HCM" alt="Quảng cáo VCCI HCM"
fill fill
className="h-full w-full object-cover" className="h-full w-full object-cover"
unoptimized
/> />
</div> </div>
</Link> </Link>
......
...@@ -55,6 +55,16 @@ export interface CmsPostContentSection { ...@@ -55,6 +55,16 @@ export interface CmsPostContentSection {
images: CmsPostContentImage[]; images: CmsPostContentImage[];
} }
export interface CmsUserSummary {
id: string;
email: string;
username: string | null;
first_name: string | null;
last_name: string | null;
full_name: string;
avatar_url: string | null;
}
export interface CmsNewsItem { export interface CmsNewsItem {
id: string; id: string;
title: string; title: string;
...@@ -82,7 +92,10 @@ export interface CmsNewsItem { ...@@ -82,7 +92,10 @@ export interface CmsNewsItem {
registration_deadline: string; registration_deadline: string;
location: string; location: string;
participation_fee: string; participation_fee: string;
event_dates: string[];
post_content: CmsPostContentSection[]; post_content: CmsPostContentSection[];
creator: CmsUserSummary | null;
editor: CmsUserSummary | null;
} }
export interface CmsHeaderCategoryItem { export interface CmsHeaderCategoryItem {
...@@ -169,7 +182,20 @@ interface CmsRawPostItem { ...@@ -169,7 +182,20 @@ interface CmsRawPostItem {
registration_deadline?: string | null; registration_deadline?: string | null;
location?: string | null; location?: string | null;
participation_fee?: string | null; participation_fee?: string | null;
event_dates?: string[] | null;
content_structure?: Record<string, unknown> | null; content_structure?: Record<string, unknown> | null;
creator?: CmsRawUser | null;
editor?: CmsRawUser | null;
}
interface CmsRawUser {
id?: string | null;
email?: string | null;
username?: string | null;
first_name?: string | null;
last_name?: string | null;
full_name?: string | null;
avatar_url?: string | null;
} }
interface CmsPivotItem { interface CmsPivotItem {
...@@ -182,6 +208,26 @@ interface CmsPivotItem { ...@@ -182,6 +208,26 @@ interface CmsPivotItem {
const isObject = (value: unknown): value is Record<string, unknown> => const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value); typeof value === "object" && value !== null && !Array.isArray(value);
const normalizeUser = (user: CmsRawUser | null | undefined): CmsUserSummary | null => {
if (!user || typeof user !== "object" || !user.id) return null;
const firstName = String(user.first_name ?? "").trim();
const lastName = String(user.last_name ?? "").trim();
const fullName =
String(user.full_name ?? "").trim() ||
[firstName, lastName].filter(Boolean).join(" ").trim() ||
String(user.username ?? "").trim() ||
String(user.email ?? "").trim();
return {
id: String(user.id),
email: String(user.email ?? ""),
username: user.username ? String(user.username) : null,
first_name: firstName || null,
last_name: lastName || null,
full_name: fullName,
avatar_url: user.avatar_url ? String(user.avatar_url) : null,
};
};
const readMessage = (payload: unknown) => { const readMessage = (payload: unknown) => {
if (!isObject(payload)) return "Request failed"; if (!isObject(payload)) return "Request failed";
if (typeof payload.message === "string" && payload.message.trim()) return payload.message; if (typeof payload.message === "string" && payload.message.trim()) return payload.message;
...@@ -367,7 +413,12 @@ const transformPost = ( ...@@ -367,7 +413,12 @@ const transformPost = (
registration_deadline: normalizeDateTimeInput(post.registration_deadline), registration_deadline: normalizeDateTimeInput(post.registration_deadline),
location: post.location ?? "", location: post.location ?? "",
participation_fee: post.participation_fee ?? "", participation_fee: post.participation_fee ?? "",
event_dates: Array.isArray(post.event_dates)
? post.event_dates.filter((d): d is string => typeof d === "string")
: [],
post_content: fallbackContent, post_content: fallbackContent,
creator: normalizeUser(post.creator),
editor: normalizeUser(post.editor),
}; };
}; };
...@@ -802,6 +853,7 @@ export async function createCmsNewsItem(input: { ...@@ -802,6 +853,7 @@ export async function createCmsNewsItem(input: {
registration_deadline?: string | null; registration_deadline?: string | null;
location?: string; location?: string;
participation_fee?: string; participation_fee?: string;
event_dates?: string[] | null;
post_content: CmsPostContentSection[]; post_content: CmsPostContentSection[];
}) { }) {
const payload = { const payload = {
...@@ -823,6 +875,7 @@ export async function createCmsNewsItem(input: { ...@@ -823,6 +875,7 @@ export async function createCmsNewsItem(input: {
registration_deadline: input.registration_deadline || null, registration_deadline: input.registration_deadline || null,
location: input.location?.trim() || null, location: input.location?.trim() || null,
participation_fee: input.participation_fee?.trim() || null, participation_fee: input.participation_fee?.trim() || null,
event_dates: input.event_dates ?? null,
release_mode: input.published_at ? "SCHEDULED" : "NOW", release_mode: input.published_at ? "SCHEDULED" : "NOW",
release_at: input.published_at || null, release_at: input.published_at || null,
content_structure: { content_structure: {
...@@ -863,6 +916,7 @@ export async function updateCmsNewsItem( ...@@ -863,6 +916,7 @@ export async function updateCmsNewsItem(
registration_deadline?: string | null; registration_deadline?: string | null;
location?: string; location?: string;
participation_fee?: string; participation_fee?: string;
event_dates?: string[] | null;
post_content: CmsPostContentSection[]; post_content: CmsPostContentSection[];
}, },
) { ) {
...@@ -885,6 +939,7 @@ export async function updateCmsNewsItem( ...@@ -885,6 +939,7 @@ export async function updateCmsNewsItem(
registration_deadline: input.registration_deadline || null, registration_deadline: input.registration_deadline || null,
location: input.location?.trim() || null, location: input.location?.trim() || null,
participation_fee: input.participation_fee?.trim() || null, participation_fee: input.participation_fee?.trim() || null,
event_dates: input.event_dates ?? null,
release_mode: input.published_at ? "SCHEDULED" : "NOW", release_mode: input.published_at ? "SCHEDULED" : "NOW",
release_at: input.published_at || null, release_at: input.published_at || null,
content_structure: { content_structure: {
...@@ -909,3 +964,14 @@ export async function deleteCmsNewsItem(id: string) { ...@@ -909,3 +964,14 @@ export async function deleteCmsNewsItem(id: string) {
headers: authHeaders(false), headers: authHeaders(false),
}); });
} }
export async function toggleCmsNewsVisibility(id: string, isHidden: boolean) {
return cmsRequest<CmsRawPostItem>(`/post/${id}`, {
method: "PUT",
headers: authHeaders(),
body: JSON.stringify({
is_hidden: isHidden,
is_active: !isHidden,
}),
});
}
...@@ -30,6 +30,7 @@ export type HomePostItem = { ...@@ -30,6 +30,7 @@ export type HomePostItem = {
isActive: boolean; isActive: boolean;
status: string; status: string;
type: string; type: string;
eventDates?: string[];
categories: HomePostCategory[]; categories: HomePostCategory[];
thumbnail: { url: string; alt: string } | null; thumbnail: { url: string; alt: string } | null;
}; };
...@@ -126,6 +127,7 @@ const buildPost = (params: BuildPostParams): HomePostItem => ({ ...@@ -126,6 +127,7 @@ const buildPost = (params: BuildPostParams): HomePostItem => ({
isActive: true, isActive: true,
status: "published", status: "published",
type: "news", type: "news",
eventDates: [],
categories: [ categories: [
buildCategory( buildCategory(
params.categoryId, params.categoryId,
......
...@@ -56,6 +56,16 @@ export interface AdminNewsContentSection { ...@@ -56,6 +56,16 @@ export interface AdminNewsContentSection {
images: AdminNewsContentImage[]; images: AdminNewsContentImage[];
} }
export interface AdminNewsUser {
id: string;
email: string;
username: string | null;
first_name: string | null;
last_name: string | null;
full_name: string;
avatar_url: string | null;
}
export interface AdminNewsItem { export interface AdminNewsItem {
id: string; id: string;
title: string; title: string;
...@@ -77,7 +87,10 @@ export interface AdminNewsItem { ...@@ -77,7 +87,10 @@ export interface AdminNewsItem {
registration_deadline: string; registration_deadline: string;
location: string; location: string;
participation_fee: string; participation_fee: string;
event_dates?: string[];
post_content: AdminNewsContentSection[]; post_content: AdminNewsContentSection[];
creator?: AdminNewsUser | null;
editor?: AdminNewsUser | null;
} }
export interface AdminNewsFormValues { export interface AdminNewsFormValues {
...@@ -100,6 +113,7 @@ export interface AdminNewsFormValues { ...@@ -100,6 +113,7 @@ export interface AdminNewsFormValues {
registration_deadline: string; registration_deadline: string;
location: string; location: string;
participation_fee: string; participation_fee: string;
event_dates?: string[];
post_content: AdminNewsContentSection[]; post_content: AdminNewsContentSection[];
} }
...@@ -123,6 +137,7 @@ export const EMPTY_ADMIN_NEWS_FORM: AdminNewsFormValues = { ...@@ -123,6 +137,7 @@ export const EMPTY_ADMIN_NEWS_FORM: AdminNewsFormValues = {
registration_deadline: "", registration_deadline: "",
location: "", location: "",
participation_fee: "", participation_fee: "",
event_dates: [],
post_content: [], post_content: [],
}; };
...@@ -1324,6 +1339,7 @@ export function cloneAdminNewsFormValues(item?: AdminNewsItem | null): AdminNews ...@@ -1324,6 +1339,7 @@ export function cloneAdminNewsFormValues(item?: AdminNewsItem | null): AdminNews
registration_deadline: item.registration_deadline, registration_deadline: item.registration_deadline,
location: item.location, location: item.location,
participation_fee: item.participation_fee, participation_fee: item.participation_fee,
event_dates: [...(item.event_dates ?? [])],
post_content: item.post_content.map((section) => ({ post_content: item.post_content.map((section) => ({
...section, ...section,
images: section.images.map((image) => ({ images: section.images.map((image) => ({
......
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