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

[tag]0.1-vcci

parents 6bbee9c4 3e1d1502
Pipeline #52512 failed with stages
in 1 minute and 53 seconds
...@@ -128,6 +128,7 @@ const orvalConfig = async () => { ...@@ -128,6 +128,7 @@ const orvalConfig = async () => {
// Cập nhật khi backend thêm/xóa tag. Bỏ hết filter nếu muốn generate toàn bộ. // Cập nhật khi backend thêm/xóa tag. Bỏ hết filter nếu muốn generate toàn bộ.
tags: [ tags: [
'Authentication', 'Authentication',
'Advertisement',
'Banner', 'Banner',
'Business', 'Business',
'Category', 'Category',
...@@ -138,6 +139,7 @@ const orvalConfig = async () => { ...@@ -138,6 +139,7 @@ const orvalConfig = async () => {
'Member', 'Member',
'NewsletterSubscription', 'NewsletterSubscription',
'PageConfig', 'PageConfig',
'PasswordResetRequest',
'Permission', 'Permission',
'Position', 'Position',
'Post', 'Post',
......
This diff is collapsed.
...@@ -37,6 +37,7 @@ import type { ...@@ -37,6 +37,7 @@ import type {
GetApiV10AuthMe200, GetApiV10AuthMe200,
GetApiV10AuthProfile200, GetApiV10AuthProfile200,
LoginRequest, LoginRequest,
PostApiV10AuthForgotPasswordRequestBody,
PostApiV10AuthForgotPasswordVerifyOtp200, PostApiV10AuthForgotPasswordVerifyOtp200,
PostApiV10AuthLogin200, PostApiV10AuthLogin200,
PostApiV10AuthLogin423, PostApiV10AuthLogin423,
...@@ -1163,4 +1164,103 @@ export const usePostApiV10AuthForgotPasswordReset = <TError = ErrorType<BadReque ...@@ -1163,4 +1164,103 @@ export const usePostApiV10AuthForgotPasswordReset = <TError = ErrorType<BadReque
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
} }
/**
* User quên mật khẩu gửi yêu cầu reset về admin.
Admin sẽ xem và xử lý (reset password) thay vì gửi OTP qua email.
Anti-enumeration: luôn trả 200 kể cả khi email không tồn tại.
* @summary Forgot password - submit reset request to admin
*/
export type postApiV10AuthForgotPasswordRequestResponse200 = {
data: void
status: 200
}
export type postApiV10AuthForgotPasswordRequestResponse400 = {
data: void
status: 400
}
export type postApiV10AuthForgotPasswordRequestResponse429 = {
data: void
status: 429
}
export type postApiV10AuthForgotPasswordRequestResponseSuccess = (postApiV10AuthForgotPasswordRequestResponse200) & {
headers: Headers;
};
export type postApiV10AuthForgotPasswordRequestResponseError = (postApiV10AuthForgotPasswordRequestResponse400 | postApiV10AuthForgotPasswordRequestResponse429) & {
headers: Headers;
};
export type postApiV10AuthForgotPasswordRequestResponse = (postApiV10AuthForgotPasswordRequestResponseSuccess | postApiV10AuthForgotPasswordRequestResponseError)
export const getPostApiV10AuthForgotPasswordRequestUrl = () => {
return `/api/v1.0/auth/forgot-password/request`
}
export const postApiV10AuthForgotPasswordRequest = async (postApiV10AuthForgotPasswordRequestBody: PostApiV10AuthForgotPasswordRequestBody, options?: RequestInit): Promise<postApiV10AuthForgotPasswordRequestResponse> => {
return useCustomClient<postApiV10AuthForgotPasswordRequestResponse>(getPostApiV10AuthForgotPasswordRequestUrl(),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
postApiV10AuthForgotPasswordRequestBody,)
}
);}
export const getPostApiV10AuthForgotPasswordRequestMutationOptions = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10AuthForgotPasswordRequest>>, TError,{data: BodyType<PostApiV10AuthForgotPasswordRequestBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof postApiV10AuthForgotPasswordRequest>>, TError,{data: BodyType<PostApiV10AuthForgotPasswordRequestBody>}, TContext> => {
const mutationKey = ['postApiV10AuthForgotPasswordRequest'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof postApiV10AuthForgotPasswordRequest>>, {data: BodyType<PostApiV10AuthForgotPasswordRequestBody>}> = (props) => {
const {data} = props ?? {};
return postApiV10AuthForgotPasswordRequest(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type PostApiV10AuthForgotPasswordRequestMutationResult = NonNullable<Awaited<ReturnType<typeof postApiV10AuthForgotPasswordRequest>>>
export type PostApiV10AuthForgotPasswordRequestMutationBody = BodyType<PostApiV10AuthForgotPasswordRequestBody>
export type PostApiV10AuthForgotPasswordRequestMutationError = ErrorType<void>
/**
* @summary Forgot password - submit reset request to admin
*/
export const usePostApiV10AuthForgotPasswordRequest = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10AuthForgotPasswordRequest>>, TError,{data: BodyType<PostApiV10AuthForgotPasswordRequestBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof postApiV10AuthForgotPasswordRequest>>,
TError,
{data: BodyType<PostApiV10AuthForgotPasswordRequestBody>},
TContext
> => {
const mutationOptions = getPostApiV10AuthForgotPasswordRequestMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
\ No newline at end of file
This diff is collapsed.
...@@ -374,7 +374,90 @@ export const usePutApiV10PermissionId = <TError = ErrorType<void>, ...@@ -374,7 +374,90 @@ export const usePutApiV10PermissionId = <TError = ErrorType<void>,
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
} }
/** /**
* Retrieve a list of permissions with pagination, filtering and sorting * Sync permissions từ file config vào database. Chỉ system_admin mới có quyền.
* @summary Sync permissions from config
*/
export type postApiV10PermissionSyncResponse200 = {
data: void
status: 200
}
export type postApiV10PermissionSyncResponseSuccess = (postApiV10PermissionSyncResponse200) & {
headers: Headers;
};
;
export type postApiV10PermissionSyncResponse = (postApiV10PermissionSyncResponseSuccess)
export const getPostApiV10PermissionSyncUrl = () => {
return `/api/v1.0/permission/sync`
}
export const postApiV10PermissionSync = async ( options?: RequestInit): Promise<postApiV10PermissionSyncResponse> => {
return useCustomClient<postApiV10PermissionSyncResponse>(getPostApiV10PermissionSyncUrl(),
{
...options,
method: 'POST'
}
);}
export const getPostApiV10PermissionSyncMutationOptions = <TError = ErrorType<unknown>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10PermissionSync>>, TError,void, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof postApiV10PermissionSync>>, TError,void, TContext> => {
const mutationKey = ['postApiV10PermissionSync'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof postApiV10PermissionSync>>, void> = () => {
return postApiV10PermissionSync(requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type PostApiV10PermissionSyncMutationResult = NonNullable<Awaited<ReturnType<typeof postApiV10PermissionSync>>>
export type PostApiV10PermissionSyncMutationError = ErrorType<unknown>
/**
* @summary Sync permissions from config
*/
export const usePostApiV10PermissionSync = <TError = ErrorType<unknown>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10PermissionSync>>, TError,void, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof postApiV10PermissionSync>>,
TError,
void,
TContext
> => {
const mutationOptions = getPostApiV10PermissionSyncMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* Retrieve a list of permissions with pagination, filtering and sorting. Also returns structured permissions grouped by resource.
* @summary Get all permissions * @summary Get all permissions
*/ */
export type getApiV10PermissionResponse200 = { export type getApiV10PermissionResponse200 = {
......
This diff is collapsed.
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 { AdvertisementFile } from './advertisementFile';
import type { AdvertisementType } from './advertisementType';
import type { AdvertisementStatus } from './advertisementStatus';
export interface Advertisement {
id: string;
/** @maxLength 255 */
name: string;
file_id: string;
/** @nullable */
file?: AdvertisementFile;
/**
* @maxLength 255
* @nullable
*/
alt?: string | null;
/** @maxLength 500 */
link: string;
type: AdvertisementType;
status: AdvertisementStatus;
sort_order: number;
/** @nullable */
created_at?: string | null;
/** @nullable */
created_by?: string | null;
/** @nullable */
updated_at?: string | null;
/** @nullable */
updated_by?: 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
*/
import type { AdvertisementCreateType } from './advertisementCreateType';
import type { AdvertisementCreateStatus } from './advertisementCreateStatus';
export interface AdvertisementCreate {
/** @maxLength 255 */
name: string;
file_id: string;
/**
* @maxLength 255
* @nullable
*/
alt?: string | null;
/** @maxLength 500 */
link: string;
type?: AdvertisementCreateType;
status?: AdvertisementCreateStatus;
sort_order?: number;
}
/**
* 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 AdvertisementCreateStatus = typeof AdvertisementCreateStatus[keyof typeof AdvertisementCreateStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const AdvertisementCreateStatus = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} 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 AdvertisementCreateType = typeof AdvertisementCreateType[keyof typeof AdvertisementCreateType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const AdvertisementCreateType = {
square: 'square',
horizontal: 'horizontal',
} 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 interface AdvertisementFile {
id?: string;
path?: string;
mime?: 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
*/
import type { AdvertisementCreate } from './advertisementCreate';
export type AdvertisementMutate = AdvertisementCreate;
/**
* 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 AdvertisementStatus = typeof AdvertisementStatus[keyof typeof AdvertisementStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const AdvertisementStatus = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} 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 AdvertisementType = typeof AdvertisementType[keyof typeof AdvertisementType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const AdvertisementType = {
square: 'square',
horizontal: 'horizontal',
} 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
*/
import type { AdvertisementUpdateType } from './advertisementUpdateType';
import type { AdvertisementUpdateStatus } from './advertisementUpdateStatus';
export interface AdvertisementUpdate {
/** @maxLength 255 */
name?: string;
file_id?: string;
/**
* @maxLength 255
* @nullable
*/
alt?: string | null;
/** @maxLength 500 */
link?: string;
type?: AdvertisementUpdateType;
status?: AdvertisementUpdateStatus;
sort_order?: number;
}
/**
* 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 AdvertisementUpdateStatus = typeof AdvertisementUpdateStatus[keyof typeof AdvertisementUpdateStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const AdvertisementUpdateStatus = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} 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 AdvertisementUpdateType = typeof AdvertisementUpdateType[keyof typeof AdvertisementUpdateType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const AdvertisementUpdateType = {
square: 'square',
horizontal: 'horizontal',
} 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
*/
import type { ApiResponse } from './apiResponse';
import type { DeleteApiV10AdvertisementId200AllOf } from './deleteApiV10AdvertisementId200AllOf';
export type DeleteApiV10AdvertisementId200 = ApiResponse & DeleteApiV10AdvertisementId200AllOf;
/**
* 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 DeleteApiV10AdvertisementId200AllOf = {
responseData?: boolean;
};
/**
* 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 DeleteApiV10UserIdRoleBody = {
/** Role ID to remove */
role_id?: 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
*/
import type { ApiResponse } from './apiResponse';
import type { GetApiV10AdvertisementId200AllOf } from './getApiV10AdvertisementId200AllOf';
export type GetApiV10AdvertisementId200 = ApiResponse & GetApiV10AdvertisementId200AllOf;
/**
* 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 { Advertisement } from './advertisement';
export type GetApiV10AdvertisementId200AllOf = {
responseData?: Advertisement;
};
/**
* 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 { FiltersParameter } from './filtersParameter';
import type { SortFieldParameter } from './sortFieldParameter';
import type { SortOrderParameter } from './sortOrderParameter';
import type { PageParameter } from './pageParameter';
import type { PageSizeParameter } from './pageSizeParameter';
export type GetApiV10AdvertisementParams = {
/**
* filter, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax
*/
filters?: FiltersParameter;
/**
* sortField, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax
*/
sortField?: SortFieldParameter;
/**
* sort order, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax
*/
sortOrder?: SortOrderParameter;
/**
* page, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax
* @minimum 1
*/
page?: PageParameter;
/**
* pageSize, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax
* @minimum 1
*/
pageSize?: PageSizeParameter;
};
/**
* 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 { GetApiV10AdvertisementPublic200AllOf } from './getApiV10AdvertisementPublic200AllOf';
export type GetApiV10AdvertisementPublic200 = ApiResponse & GetApiV10AdvertisementPublic200AllOf;
/**
* 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 { Advertisement } from './advertisement';
export type GetApiV10AdvertisementPublic200AllOf = {
responseData?: Advertisement[];
};
/**
* 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 { GetApiV10AdvertisementPublicType } from './getApiV10AdvertisementPublicType';
export type GetApiV10AdvertisementPublicParams = {
/**
* Advertisement type (square or horizontal). If omitted, returns all active.
*/
type?: GetApiV10AdvertisementPublicType;
/**
* Max number of records to return (default 20)
* @minimum 1
* @maximum 100
*/
limit?: number;
};
/**
* 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 GetApiV10AdvertisementPublicType = typeof GetApiV10AdvertisementPublicType[keyof typeof GetApiV10AdvertisementPublicType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const GetApiV10AdvertisementPublicType = {
square: 'square',
horizontal: 'horizontal',
} 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
*/
import type { GetApiV10PasswordResetRequestStatus } from './getApiV10PasswordResetRequestStatus';
import type { GetApiV10PasswordResetRequestSortOrder } from './getApiV10PasswordResetRequestSortOrder';
export type GetApiV10PasswordResetRequestParams = {
page?: number;
pageSize?: number;
status?: GetApiV10PasswordResetRequestStatus;
sortField?: string;
sortOrder?: GetApiV10PasswordResetRequestSortOrder;
};
/**
* 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 GetApiV10PasswordResetRequestSortOrder = typeof GetApiV10PasswordResetRequestSortOrder[keyof typeof GetApiV10PasswordResetRequestSortOrder];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const GetApiV10PasswordResetRequestSortOrder = {
asc: 'asc',
desc: 'desc',
} 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 GetApiV10PasswordResetRequestStatus = typeof GetApiV10PasswordResetRequestStatus[keyof typeof GetApiV10PasswordResetRequestStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const GetApiV10PasswordResetRequestStatus = {
PENDING: 'PENDING',
RESOLVED: 'RESOLVED',
REJECTED: 'REJECTED',
all: 'all',
} as const;
...@@ -6,6 +6,17 @@ ...@@ -6,6 +6,17 @@
* OpenAPI spec version: 1.0.0 * OpenAPI spec version: 1.0.0
*/ */
export * from './advertisement';
export * from './advertisementCreate';
export * from './advertisementCreateStatus';
export * from './advertisementCreateType';
export * from './advertisementFile';
export * from './advertisementMutate';
export * from './advertisementStatus';
export * from './advertisementType';
export * from './advertisementUpdate';
export * from './advertisementUpdateStatus';
export * from './advertisementUpdateType';
export * from './apiResponse'; export * from './apiResponse';
export * from './apiResponseResponseData'; export * from './apiResponseResponseData';
export * from './apiResponseViolationItem'; export * from './apiResponseViolationItem';
...@@ -41,6 +52,8 @@ export * from './categoryTagMutate'; ...@@ -41,6 +52,8 @@ export * from './categoryTagMutate';
export * from './categoryThumbnail'; export * from './categoryThumbnail';
export * from './contact'; export * from './contact';
export * from './contactMutate'; export * from './contactMutate';
export * from './deleteApiV10AdvertisementId200';
export * from './deleteApiV10AdvertisementId200AllOf';
export * from './deleteApiV10BannerId200'; export * from './deleteApiV10BannerId200';
export * from './deleteApiV10BannerId200AllOf'; export * from './deleteApiV10BannerId200AllOf';
export * from './deleteApiV10CategoryId200'; export * from './deleteApiV10CategoryId200';
...@@ -69,6 +82,7 @@ export * from './deleteApiV10RolePermissionRoleRoleId200AllOf'; ...@@ -69,6 +82,7 @@ export * from './deleteApiV10RolePermissionRoleRoleId200AllOf';
export * from './deleteApiV10RolePermissionRoleRoleId200AllOfResponseData'; export * from './deleteApiV10RolePermissionRoleRoleId200AllOfResponseData';
export * from './deleteApiV10TermId200'; export * from './deleteApiV10TermId200';
export * from './deleteApiV10TermId200AllOf'; export * from './deleteApiV10TermId200AllOf';
export * from './deleteApiV10UserIdRoleBody';
export * from './deleteApiV10UserRoleId200'; export * from './deleteApiV10UserRoleId200';
export * from './deleteApiV10UserRoleId200AllOf'; export * from './deleteApiV10UserRoleId200AllOf';
export * from './deleteApiV10UserRoleId200AllOfResponseData'; export * from './deleteApiV10UserRoleId200AllOfResponseData';
...@@ -84,6 +98,13 @@ export * from './forgotPasswordResetRequest'; ...@@ -84,6 +98,13 @@ export * from './forgotPasswordResetRequest';
export * from './forgotPasswordSendOtpRequest'; export * from './forgotPasswordSendOtpRequest';
export * from './forgotPasswordVerifyOtpRequest'; export * from './forgotPasswordVerifyOtpRequest';
export * from './forgotPasswordVerifyOtpResponse'; export * from './forgotPasswordVerifyOtpResponse';
export * from './getApiV10AdvertisementId200';
export * from './getApiV10AdvertisementId200AllOf';
export * from './getApiV10AdvertisementParams';
export * from './getApiV10AdvertisementPublic200';
export * from './getApiV10AdvertisementPublic200AllOf';
export * from './getApiV10AdvertisementPublicParams';
export * from './getApiV10AdvertisementPublicType';
export * from './getApiV10AuthMe200'; export * from './getApiV10AuthMe200';
export * from './getApiV10AuthMe200AllOf'; export * from './getApiV10AuthMe200AllOf';
export * from './getApiV10AuthProfile200'; export * from './getApiV10AuthProfile200';
...@@ -109,6 +130,9 @@ export * from './getApiV10LogoParams'; ...@@ -109,6 +130,9 @@ export * from './getApiV10LogoParams';
export * from './getApiV10MemberParams'; export * from './getApiV10MemberParams';
export * from './getApiV10NewsletterSubscriptionParams'; export * from './getApiV10NewsletterSubscriptionParams';
export * from './getApiV10PageConfigParams'; export * from './getApiV10PageConfigParams';
export * from './getApiV10PasswordResetRequestParams';
export * from './getApiV10PasswordResetRequestSortOrder';
export * from './getApiV10PasswordResetRequestStatus';
export * from './getApiV10PermissionId200'; export * from './getApiV10PermissionId200';
export * from './getApiV10PermissionId200AllOf'; export * from './getApiV10PermissionId200AllOf';
export * from './getApiV10PermissionParams'; export * from './getApiV10PermissionParams';
...@@ -189,6 +213,8 @@ export * from './patchApiV10NewsletterSubscriptionId200'; ...@@ -189,6 +213,8 @@ export * from './patchApiV10NewsletterSubscriptionId200';
export * from './patchApiV10NewsletterSubscriptionId200AllOf'; export * from './patchApiV10NewsletterSubscriptionId200AllOf';
export * from './patchApiV10TagId200'; export * from './patchApiV10TagId200';
export * from './patchApiV10TagId200AllOf'; export * from './patchApiV10TagId200AllOf';
export * from './patchApiV10UserIdStatusBody';
export * from './patchApiV10UserIdStatusBodyStatus';
export * from './patchApiV10VideoId200'; export * from './patchApiV10VideoId200';
export * from './patchApiV10VideoId200AllOf'; export * from './patchApiV10VideoId200AllOf';
export * from './permission'; export * from './permission';
...@@ -200,6 +226,9 @@ export * from './permissionUpdate'; ...@@ -200,6 +226,9 @@ export * from './permissionUpdate';
export * from './position'; export * from './position';
export * from './positionMutate'; export * from './positionMutate';
export * from './post'; export * from './post';
export * from './postApiV10Advertisement200';
export * from './postApiV10Advertisement200AllOf';
export * from './postApiV10AuthForgotPasswordRequestBody';
export * from './postApiV10AuthForgotPasswordVerifyOtp200'; export * from './postApiV10AuthForgotPasswordVerifyOtp200';
export * from './postApiV10AuthLogin200'; export * from './postApiV10AuthLogin200';
export * from './postApiV10AuthLogin200Message'; export * from './postApiV10AuthLogin200Message';
...@@ -234,6 +263,8 @@ export * from './postApiV10MemberImportBody'; ...@@ -234,6 +263,8 @@ export * from './postApiV10MemberImportBody';
export * from './postApiV10NewsletterSubscription200'; export * from './postApiV10NewsletterSubscription200';
export * from './postApiV10NewsletterSubscription200AllOf'; export * from './postApiV10NewsletterSubscription200AllOf';
export * from './postApiV10PageConfigBody'; export * from './postApiV10PageConfigBody';
export * from './postApiV10PasswordResetRequestIdRejectBody';
export * from './postApiV10PasswordResetRequestIdResolveBody';
export * from './postApiV10PermissionBulk200'; export * from './postApiV10PermissionBulk200';
export * from './postApiV10PermissionBulk200AllOf'; export * from './postApiV10PermissionBulk200AllOf';
export * from './postApiV10Position200'; export * from './postApiV10Position200';
...@@ -264,6 +295,7 @@ export * from './postApiV10PostTagPostIdBulk200'; ...@@ -264,6 +295,7 @@ export * from './postApiV10PostTagPostIdBulk200';
export * from './postApiV10PostTagPostIdBulk200AllOf'; export * from './postApiV10PostTagPostIdBulk200AllOf';
export * from './postApiV10PostTagPostIdBulk200AllOfResponseDataItem'; export * from './postApiV10PostTagPostIdBulk200AllOfResponseDataItem';
export * from './postApiV10PostTagPostIdBulkBody'; export * from './postApiV10PostTagPostIdBulkBody';
export * from './postApiV10RoleBody';
export * from './postApiV10RoleBulk200'; export * from './postApiV10RoleBulk200';
export * from './postApiV10RoleBulk200AllOf'; export * from './postApiV10RoleBulk200AllOf';
export * from './postApiV10RolePermissionRoleRoleIdBulk200'; export * from './postApiV10RolePermissionRoleRoleIdBulk200';
...@@ -275,6 +307,7 @@ export * from './postApiV10TagIds200AllOf'; ...@@ -275,6 +307,7 @@ export * from './postApiV10TagIds200AllOf';
export * from './postApiV10TagIdsBody'; export * from './postApiV10TagIdsBody';
export * from './postApiV10Term201'; export * from './postApiV10Term201';
export * from './postApiV10Term201AllOf'; export * from './postApiV10Term201AllOf';
export * from './postApiV10UserIdRoleBody';
export * from './postApiV10UserRole200'; export * from './postApiV10UserRole200';
export * from './postApiV10UserRole200AllOf'; export * from './postApiV10UserRole200AllOf';
export * from './postApiV10Video200'; export * from './postApiV10Video200';
...@@ -288,6 +321,8 @@ export * from './postMutateContentStructure'; ...@@ -288,6 +321,8 @@ export * from './postMutateContentStructure';
export * from './postTag'; export * from './postTag';
export * from './postTagBulkCreate'; export * from './postTagBulkCreate';
export * from './postTagMutate'; export * from './postTagMutate';
export * from './putApiV10AdvertisementId200';
export * from './putApiV10AdvertisementId200AllOf';
export * from './putApiV10BannerId200'; export * from './putApiV10BannerId200';
export * from './putApiV10BannerId200AllOf'; export * from './putApiV10BannerId200AllOf';
export * from './putApiV10CategoryId200'; export * from './putApiV10CategoryId200';
......
/**
* 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 { PatchApiV10UserIdStatusBodyStatus } from './patchApiV10UserIdStatusBodyStatus';
export type PatchApiV10UserIdStatusBody = {
/** Trạng thái mới của user */
status?: PatchApiV10UserIdStatusBodyStatus;
};
/**
* 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
*/
/**
* Trạng thái mới của user
*/
export type PatchApiV10UserIdStatusBodyStatus = typeof PatchApiV10UserIdStatusBodyStatus[keyof typeof PatchApiV10UserIdStatusBodyStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const PatchApiV10UserIdStatusBodyStatus = {
active: 'active',
inactive: 'inactive',
} 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
*/
import type { ApiResponse } from './apiResponse';
import type { PostApiV10Advertisement200AllOf } from './postApiV10Advertisement200AllOf';
export type PostApiV10Advertisement200 = ApiResponse & PostApiV10Advertisement200AllOf;
/**
* 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 { Advertisement } from './advertisement';
export type PostApiV10Advertisement200AllOf = {
responseData?: Advertisement;
};
/**
* 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 PostApiV10AuthForgotPasswordRequestBody = {
email: string;
/** Ghi chú thêm từ user (tên, lý do, etc.) */
note?: 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 PostApiV10PasswordResetRequestIdRejectBody = {
/** Lý do từ chối */
resolveNote?: 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 PostApiV10PasswordResetRequestIdResolveBody = {
/** Mật khẩu mới (nếu bỏ trống sẽ dùng vcci@2026) */
newPassword?: string;
/** Ghi chú của admin */
resolveNote?: 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 PostApiV10RoleBody = {
name?: string;
description?: string;
permissions?: 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 PostApiV10UserIdRoleBody = {
/** Role ID to assign */
role_id?: string;
/** Set as primary role */
is_primary?: boolean;
};
/**
* 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 { PutApiV10AdvertisementId200AllOf } from './putApiV10AdvertisementId200AllOf';
export type PutApiV10AdvertisementId200 = ApiResponse & PutApiV10AdvertisementId200AllOf;
/**
* 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 { Advertisement } from './advertisement';
export type PutApiV10AdvertisementId200AllOf = {
responseData?: Advertisement;
};
...@@ -23,4 +23,6 @@ export interface Role { ...@@ -23,4 +23,6 @@ export interface Role {
created_by?: string | null; created_by?: string | null;
/** @nullable */ /** @nullable */
updated_by?: string | null; updated_by?: string | null;
/** Danh sách permission strings (vd: 'posts:read', 'posts:write') */
permissions?: string[];
} }
...@@ -14,4 +14,6 @@ export interface RoleCreate { ...@@ -14,4 +14,6 @@ export interface RoleCreate {
* @nullable * @nullable
*/ */
description?: string | null; description?: string | null;
/** Danh sách permission strings */
permissions?: string[];
} }
...@@ -14,4 +14,6 @@ export interface RoleUpdate { ...@@ -14,4 +14,6 @@ export interface RoleUpdate {
* @nullable * @nullable
*/ */
description?: string | null; description?: string | null;
/** Danh sách permission strings (ghi đà toàn bộ permissions hiện tại) */
permissions?: string[];
} }
...@@ -3,8 +3,9 @@ ...@@ -3,8 +3,9 @@
import ImageNext from "@/components/shared/image-next"; import ImageNext from "@/components/shared/image-next";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts"; import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ChevronRight, Mail, Phone } from "lucide-react"; import { ChevronRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import HorizontalAdBanner from "@/app/(main)/(home)/components/horizontal-ad-banner";
const FALLBACK_CATEGORY_LINK = "/hoat-dong/tin-tuc"; const FALLBACK_CATEGORY_LINK = "/hoat-dong/tin-tuc";
...@@ -36,7 +37,7 @@ function FeaturedNews() { ...@@ -36,7 +37,7 @@ function FeaturedNews() {
</Link> </Link>
</div> </div>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.14fr)_minmax(0,0.96fr)]"> <div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
{primaryItem ? ( {primaryItem ? (
<Link <Link
href={primaryItem.externalLink} href={primaryItem.externalLink}
...@@ -57,7 +58,7 @@ function FeaturedNews() { ...@@ -57,7 +58,7 @@ function FeaturedNews() {
{primaryItem.categories[0]?.name || "Tin nổi bật"} {primaryItem.categories[0]?.name || "Tin nổi bật"}
</span> </span>
<h3 className="max-w-3xl line-clamp-2 text-[16px] font-bold leading-[1.32] text-white transition-colors duration-200 group-hover:text-[#f7b500] md:line-clamp-3 md:text-[28px] lg:text-[32px]"> <h3 className="max-w-3xl line-clamp-2 text-[16px] font-bold leading-[1.32] text-white transition-colors duration-200 group-hover:text-[#f7b500] md:line-clamp-3 md:text-[22px] lg:text-[24px]">
{primaryItem.title} {primaryItem.title}
</h3> </h3>
...@@ -79,16 +80,16 @@ function FeaturedNews() { ...@@ -79,16 +80,16 @@ function FeaturedNews() {
</div> </div>
)} )}
<div className="grid gap-4"> <div className="flex flex-col gap-4 lg:min-h-[380px]">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid flex-1 gap-4 md:grid-cols-2">
{secondarySlots.map((item, index) => {secondarySlots.map((item, index) =>
item ? ( item ? (
<Link <Link
key={item.id} key={item.id}
href={item.externalLink} href={item.externalLink}
className="group relative block cursor-pointer overflow-hidden rounded-[20px] bg-[#27447f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] md:min-h-[205px] lg:min-h-[215px]" className="group relative block cursor-pointer overflow-hidden rounded-[20px] bg-[#27447f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] min-h-[165px]"
> >
<div className="relative h-full min-h-[195px] md:min-h-[205px] lg:min-h-[215px]"> <div className="relative flex h-full min-h-[165px]">
<ImageNext <ImageNext
src={item.thumbnail?.url ?? "/thumbnail.png"} src={item.thumbnail?.url ?? "/thumbnail.png"}
alt={item.thumbnail?.alt || item.title} alt={item.thumbnail?.alt || item.title}
...@@ -118,9 +119,9 @@ function FeaturedNews() { ...@@ -118,9 +119,9 @@ function FeaturedNews() {
) : ( ) : (
<div <div
key={`featured-placeholder-${index}`} key={`featured-placeholder-${index}`}
className="rounded-[20px] bg-[#dde5f3] shadow-[0_16px_32px_rgba(28,52,120,0.1)] md:min-h-[205px] lg:min-h-[215px]" className="rounded-[20px] bg-[#dde5f3] shadow-[0_16px_32px_rgba(28,52,120,0.1)] min-h-[165px]"
> >
<div className="flex h-full min-h-[195px] flex-col justify-end p-3.5 md:min-h-[205px] lg:min-h-[215px]"> <div className="flex h-full min-h-[165px] flex-col justify-end p-3.5">
<span className="mb-2 h-7 w-24 rounded-[10px] bg-white/80" /> <span className="mb-2 h-7 w-24 rounded-[10px] bg-white/80" />
<div className="h-6 w-5/6 rounded bg-white/90" /> <div className="h-6 w-5/6 rounded bg-white/90" />
<div className="mt-2 h-4 w-24 rounded bg-white/70" /> <div className="mt-2 h-4 w-24 rounded bg-white/70" />
...@@ -130,32 +131,7 @@ function FeaturedNews() { ...@@ -130,32 +131,7 @@ function FeaturedNews() {
)} )}
</div> </div>
<div className="flex h-full min-h-full items-center justify-center overflow-hidden rounded-[28px] bg-linear-to-r from-[#214b95] to-[#2b66bb] px-5 py-5 text-white shadow-[0_18px_38px_rgba(28,52,120,0.2)] md:px-7"> <HorizontalAdBanner />
<div className="flex w-full flex-col items-center justify-center gap-4 text-center md:flex-row md:gap-6 md:text-left lg:gap-8 xl:gap-12">
<div className="flex flex-col items-center md:items-start">
<p className="text-[11px] uppercase tracking-[0.2em] text-white/80 lg:text-[12px] lg:tracking-[0.4em]">
Quảng bá & tiếp cận
</p>
<h3 className="mt-2 text-[20px] font-extrabold uppercase leading-[1.1] lg:text-[18px] xl:text-[22px]">
<span>Cộng đồng</span>
<br className="hidden lg:block" />
<span className="lg:hidden"> </span>
<span>doanh nghiệp</span>
</h3>
</div>
<div className="shrink-0 flex flex-col gap-2 rounded-[20px] bg-white px-4 py-3 text-[#173f88] shadow-[0_10px_24px_rgba(8,25,74,0.12)] sm:min-w-fit lg:w-auto lg:rounded-[999px]">
<div className="flex items-center gap-2 text-[13px] font-medium sm:text-sm">
<Mail className="h-4 w-4 shrink-0 sm:h-5 sm:w-5" />
<span className="whitespace-nowrap">info@vcci-hcm.org.vn</span>
</div>
<div className="flex items-center gap-2 text-[13px] font-medium sm:text-sm">
<Phone className="h-4 w-4 shrink-0 sm:h-5 sm:w-5" />
<span className="whitespace-nowrap">+84 28 3932 6598</span>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
......
'use client';
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
import { resolveUploadUrl } from "@/links";
const FALLBACK_HREF = "https://vcci-hcm.org.vn";
const FALLBACK_SRC = "/quang-cao/qc-1.jpg";
/**
* Banner quảng cáo ngang (full-width) giữa các section.
* Luôn hiển thị 1 banner duy nhất (record đầu tiên theo sort_order).
* Dù có nhiều vị trí đặt component, tất cả đều hiển thị cùng 1 banner.
* Fallback: nếu API không có data hoặc ảnh lỗi → dùng /quang-cao/qc-1.jpg.
*/
function HorizontalAdBanner() {
const ads = useAdvertisements("horizontal");
const ad = ads[0];
const href = ad?.link || FALLBACK_HREF;
const initialSrc = ad?.file?.path ? resolveUploadUrl(ad.file.path) : FALLBACK_SRC;
const [src, setSrc] = useState(initialSrc);
const [errored, setErrored] = useState(false);
// Reset state khi ad thay đổi
if (ad?.file?.path && !errored && src !== initialSrc) {
setSrc(initialSrc);
}
const isGif = src.toLowerCase().endsWith(".gif");
const title = ad?.name || "Quảng cáo VCCI HCM";
const alt = ad?.alt || title;
return (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
className="relative block overflow-hidden rounded-[10px] shadow-[0_16px_32px_rgba(28,52,120,0.2)] lg:rounded-[20px]"
style={{ aspectRatio: "1600 / 200" }}
title={title}
>
<Image
src={src}
alt={alt}
fill
className="object-cover"
unoptimized={isGif}
onError={() => {
if (src !== FALLBACK_SRC) {
setSrc(FALLBACK_SRC);
setErrored(true);
}
}}
/>
</Link>
);
}
export default HorizontalAdBanner;
'use client'; 'use client';
import ImageNext from "@/components/shared/image-next"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
import { resolveUploadUrl } from "@/links";
import type { Advertisement } from "@/api/models/advertisement";
const FALLBACK_SRC = "/quang-cao/qc-3.jpg";
const FALLBACK_HREF = "https://vcci-hcm.org.vn";
function AdItem({ item }: { item: Advertisement }) {
const initialSrc = item.file?.path ? resolveUploadUrl(item.file.path) : FALLBACK_SRC;
const [src, setSrc] = useState(initialSrc);
const isGif = src.toLowerCase().endsWith(".gif");
function Advertisements({ count = 2 }: { count?: number }) {
const mdCols = count >= 3 ? "md:grid-cols-3" : "md:grid-cols-2";
return ( return (
<aside className={`flex w-full flex-col gap-4 md:grid ${mdCols} xl:grid xl:order-2 xl:w-[22%] xl:grid-cols-1 xl:gap-4 xl:self-center`}>
<Link <Link
href="https://hardwaretools.com.vn/" href={item.link}
target="_blank"
rel="noopener noreferrer"
className="block overflow-hidden rounded-[28px] shadow-[0_12px_28px_rgba(31,59,124,0.14)]" className="block overflow-hidden rounded-[28px] shadow-[0_12px_28px_rgba(31,59,124,0.14)]"
title={item.name}
> >
<div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]"> <div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]">
<ImageNext <Image
src="/home/20-2048x1365.webp" src={src}
alt="Quảng cáo 1" alt={item.alt || item.name}
width={2048} width={2048}
height={1365} height={1365}
className="h-full w-full object-cover object-[center_80%]" className="h-full w-full object-cover object-[center_80%]"
unoptimized={isGif}
onError={() => {
if (src !== FALLBACK_SRC) setSrc(FALLBACK_SRC);
}}
/> />
</div> </div>
</Link> </Link>
);
}
function FallbackAdItem() {
return (
<Link <Link
href="https://hardwaretools.com.vn/" href={FALLBACK_HREF}
target="_blank"
rel="noopener noreferrer"
className="block overflow-hidden rounded-[28px] shadow-[0_12px_28px_rgba(31,59,124,0.14)]" className="block overflow-hidden rounded-[28px] shadow-[0_12px_28px_rgba(31,59,124,0.14)]"
title="Quảng cáo VCCI HCM"
> >
<div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]"> <div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]">
<ImageNext <Image
src="/home/20-2048x1365.webp" src={FALLBACK_SRC}
alt="Quảng cáo 2" alt="Quảng cáo VCCI HCM"
width={2048} width={2048}
height={1365} height={1365}
className="h-full w-full object-cover object-[center_80%]" className="h-full w-full object-cover object-[center_80%]"
/> />
</div> </div>
</Link> </Link>
);
}
{count >= 3 && ( function Advertisements({ count = 2, startIndex = 0 }: { count?: number; startIndex?: number }) {
<Link const ads = useAdvertisements("square");
href="https://hardwaretools.com.vn/" const visibleAds = ads.slice(startIndex, startIndex + count);
className="block overflow-hidden rounded-[28px] shadow-[0_12px_28px_rgba(31,59,124,0.14)]"
> // Fallback: nếu API không có data, hiển thị fallback item
<div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]"> const items =
<ImageNext visibleAds.length > 0
src="/home/20-2048x1365.webp" ? visibleAds.map((item) => <AdItem key={item.id} item={item} />)
alt="Quảng cáo 3" : Array.from({ length: count }).map((_, i) => <FallbackAdItem key={`fallback-${i}`} />);
width={2048}
height={1365} const mdCols = count >= 3 ? "md:grid-cols-3" : "md:grid-cols-2";
className="h-full w-full object-cover object-[center_80%]" return (
/> <aside className={`flex w-full flex-col gap-4 md:grid ${mdCols} xl:grid xl:order-2 xl:w-[22%] xl:grid-cols-1 xl:gap-4 xl:self-center`}>
</div> {items}
</Link>
)}
</aside> </aside>
); );
} }
......
"use client";
import { useGetApiV10AdvertisementPublic } from "@/api/endpoints/advertisement";
import type { Advertisement } from "@/api/models/advertisement";
import type { GetApiV10AdvertisementPublicType } from "@/api/models/getApiV10AdvertisementPublicType";
/**
* Hook đọc danh sách quảng cáo active theo loại từ API backend (public, không cần auth).
* Backend đã lọc status=ACTIVE và sort theo sort_order ASC.
* Mỗi record có `file` (path, mime) — dùng `resolveUploadUrl(file.path)` để lấy URL ảnh.
*
* @param type - "square" | "horizontal"
* @param limit - số lượng records tối đa (mặc định 20 cho square, 1 cho horizontal)
*/
export function useAdvertisements(
type: "square" | "horizontal",
limit?: number,
): Advertisement[] {
const effectiveLimit = limit ?? (type === "horizontal" ? 1 : 20);
const { data } = useGetApiV10AdvertisementPublic({
type: type as GetApiV10AdvertisementPublicType,
limit: effectiveLimit,
});
return (
(data as unknown as { responseData?: Advertisement[] } | undefined)?.responseData ?? []
);
}
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import FeaturedNews from "./components/featured-news"; import FeaturedNews from "./components/featured-news";
import Advertisements from "./components/quick-links"; import Advertisements from "./components/quick-links";
import HorizontalAdBanner from "./components/horizontal-ad-banner";
import News from "./components/news"; import News from "./components/news";
import Events from "./components/events"; import Events from "./components/events";
import BusinessOpportunities from "./components/business-opportunities"; import BusinessOpportunities from "./components/business-opportunities";
...@@ -18,22 +19,15 @@ const Page = () => { ...@@ -18,22 +19,15 @@ const Page = () => {
{/* contents */} {/* contents */}
<div className="container mx-auto px-3 sm:px-6 lg:px-10 space-y-6"> <div className="container mx-auto px-3 sm:px-6 lg:px-10 space-y-6">
<FeaturedNews /> <FeaturedNews />
{/* <div>
<Link href="https://hardwaretools.com.vn/">
<ImageNext
src="/home/Standard-Banner-1-2024.png.webp"
alt="banner"
width={2560}
height={720}
/>
</Link>
</div> */}
<section className="flex flex-col xl:flex-row pb-8 gap-5 mb-0"> <section className="flex flex-col xl:flex-row pb-8 gap-5 mb-0">
<News /> <News />
<Advertisements count={3} /> <Advertisements count={3} startIndex={0} />
</section > </section >
<HorizontalAdBanner />
<section className="flex flex-col gap-5 xl:flex-row xl:items-stretch" > <section className="flex flex-col gap-5 xl:flex-row xl:items-stretch" >
<Events /> <Events />
<EventsCalendar /> <EventsCalendar />
...@@ -57,7 +51,7 @@ const Page = () => { ...@@ -57,7 +51,7 @@ const Page = () => {
<BusinessOpportunities /> <BusinessOpportunities />
<PolicyAndLaws /> <PolicyAndLaws />
</div> </div>
<Advertisements /> <Advertisements count={2} startIndex={3} />
</section> </section>
</div> </div>
......
...@@ -5,6 +5,7 @@ import ImageNext from "@/components/shared/image-next"; ...@@ -5,6 +5,7 @@ import ImageNext from "@/components/shared/image-next";
import AppEditorContent from "@/components/shared/editor-content"; import AppEditorContent from "@/components/shared/editor-content";
import ListCategory from "@/components/base/list-category"; import ListCategory from "@/components/base/list-category";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar"; import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { Calendar, MapPin, Clock, DollarSign, Users, CreditCard } from "lucide-react"; import { Calendar, MapPin, Clock, DollarSign, Users, CreditCard } from "lucide-react";
import { buildDynamicCategoryMenu, findDisplayCategoryForPost } from "./data"; import { buildDynamicCategoryMenu, findDisplayCategoryForPost } from "./data";
import StructuredPostContent from "./StructuredPostContent"; import StructuredPostContent from "./StructuredPostContent";
...@@ -254,26 +255,7 @@ export default function ArticleDetailPage({ ...@@ -254,26 +255,7 @@ export default function ArticleDetailPage({
<aside className="space-y-5 xl:pt-0"> <aside className="space-y-5 xl:pt-0">
<EventsCalendar compact className="xl:w-full xl:min-w-0" /> <EventsCalendar compact className="xl:w-full xl:min-w-0" />
<div className="overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]"> <SidebarAdvertisements count={5} startIndex={0} />
<div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext
src="/banner.webp"
alt="Đối tác quảng bá"
width={640}
height={760}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-liner-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá
</div>
<div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho hội viên doanh nghiệp
</div>
</div>
</div>
</div>
</aside> </aside>
</div> </div>
</div> </div>
......
...@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"; ...@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category"; import ListCategory from "@/components/base/list-category";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar"; import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { import {
buildDynamicPostHref, buildDynamicPostHref,
buildDynamicCategoryMenu, buildDynamicCategoryMenu,
...@@ -250,26 +251,7 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp ...@@ -250,26 +251,7 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
<EventsCalendar compact className="xl:w-full xl:min-w-0" /> <EventsCalendar compact className="xl:w-full xl:min-w-0" />
<div className="order-3 overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)] xl:order-0"> <SidebarAdvertisements count={5} startIndex={0} />
<div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext
src="/banner.webp"
alt="Đối tác quảng bá"
width={640}
height={760}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-liner-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá
</div>
<div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho hội viên doanh nghiệp
</div>
</div>
</div>
</div>
</aside> </aside>
</div> </div>
</div> </div>
......
...@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"; ...@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category"; import ListCategory from "@/components/base/list-category";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar"; import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { import {
buildDynamicPostHref, buildDynamicPostHref,
buildDynamicCategoryMenu, buildDynamicCategoryMenu,
...@@ -188,26 +189,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp ...@@ -188,26 +189,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
<EventsCalendar compact className="xl:w-full xl:min-w-0" /> <EventsCalendar compact className="xl:w-full xl:min-w-0" />
<div className="order-3 overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)] xl:order-0"> <SidebarAdvertisements count={5} startIndex={0} />
<div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext
src="/banner.webp"
alt="Đối tác quảng bá"
width={640}
height={760}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-liner-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá
</div>
<div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho hội viên doanh nghiệp
</div>
</div>
</div>
</div>
</aside> </aside>
</div> </div>
</div> </div>
......
...@@ -5,6 +5,7 @@ import Link from "next/link"; ...@@ -5,6 +5,7 @@ import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import ImageNext from "@/components/shared/image-next"; import ImageNext from "@/components/shared/image-next";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { Pagination } from "@components/base/pagination"; import { Pagination } from "@components/base/pagination";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
...@@ -228,26 +229,7 @@ function SearchContent() { ...@@ -228,26 +229,7 @@ function SearchContent() {
</div> </div>
</form> </form>
<div className="order-3 overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)] xl:order-none"> <SidebarAdvertisements count={5} startIndex={0} />
<div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext
src="/banner.webp"
alt="Đối tác quảng bá"
width={640}
height={760}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá
</div>
<div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho doanh nghiệp hội viên
</div>
</div>
</div>
</div>
</aside> </aside>
</div> </div>
</div> </div>
......
This diff is collapsed.
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { NoPermissionMessage } from "@/components/shared/permission-gate";
import { usePermission } from "@/hooks/usePermission";
import { AdvertisementList } from "./advertisement-list";
export default function AdvertisementsPage() {
const canRead = usePermission("advertisements", "read");
if (!canRead) {
return <NoPermissionMessage />;
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-[#163b73]">Quản lý Quảng cáo</h1>
<p className="mt-1 text-sm text-slate-600">
Quản lý quảng cáo sidebar (vuông) và banner ngang trên trang chủ
</p>
</div>
{/* Tabs */}
<Tabs defaultValue="square" className="w-full">
<TabsList className="grid w-full max-w-md grid-cols-2 rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] p-1">
<TabsTrigger
value="square"
className="rounded-xl data-[state=active]:bg-[#063e8e] data-[state=active]:text-white"
>
Quảng cáo vuông (Sidebar)
</TabsTrigger>
<TabsTrigger
value="horizontal"
className="rounded-xl data-[state=active]:bg-[#063e8e] data-[state=active]:text-white"
>
Quảng cáo ngang (Banner)
</TabsTrigger>
</TabsList>
<TabsContent value="square" className="mt-6">
<AdvertisementList
type="square"
title="Quảng cáo vuông (Sidebar)"
description="Hiển thị ở sidebar các trang. Tỉ lệ ảnh khuyến nghị 16:10"
previewAspect="16 / 10"
note="Website chỉ hiển thị tối đa 5 quảng cáo vuông (theo sort_order). Các quảng cáo có thứ tự lớn hơn hoặc trạng thái 'Ẩn' sẽ không hiện trên trang."
/>
</TabsContent>
<TabsContent value="horizontal" className="mt-6">
<AdvertisementList
type="horizontal"
title="Quảng cáo ngang (Banner)"
description="Hiển thị banner full-width giữa các section. Tỉ lệ ảnh khuyến nghị 1600:200"
previewAspect="1600 / 200"
note="Website chỉ hiển thị 1 quảng cáo ngang duy nhất (quảng cáo có sort_order nhỏ nhất và trạng thái 'Hiển thị'). Tất cả vị trí banner trên trang sẽ dùng chung 1 quảng cáo này."
/>
</TabsContent>
</Tabs>
</div>
);
}
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Lock, Eye, EyeOff, KeyRound, CheckCircle2, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { usePutApiV10UserChangePassword } from "@/api/endpoints/user";
import useAuthStore from "@/store/useAuthStore";
import { logoutAdmin } from "@/lib/auth/admin-auth";
export default function ChangePasswordPage() {
const router = useRouter();
const appUser = useAuthStore((state) => state.appUser);
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showOld, setShowOld] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [done, setDone] = useState(false);
const changePasswordMutation = usePutApiV10UserChangePassword();
const validate = (): string | null => {
if (!oldPassword.trim()) return "Vui lòng nhập mật khẩu cũ";
if (newPassword.length < 6) return "Mật khẩu mới phải có ít nhất 6 ký tự";
if (newPassword === oldPassword) return "Mật khẩu mới phải khác mật khẩu cũ";
if (newPassword !== confirmPassword) return "Xác nhận mật khẩu không khớp";
return null;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const error = validate();
if (error) {
toast.error(error);
return;
}
try {
await changePasswordMutation.mutateAsync({
data: {
oldPassword,
newPassword,
},
});
setDone(true);
toast.success("Đổi mật khẩu thành công!");
// Update store để bỏ must_change_password
useAuthStore.getState().setAppUser({
...appUser,
must_change_password: false,
} as typeof appUser);
// Sau 2s, logout để user đăng nhập lại bằng mật khẩu mới
setTimeout(async () => {
await logoutAdmin({ silent: true, redirectToLogin: true });
}, 2000);
} catch (err: unknown) {
const e = err as { message?: string };
toast.error(e?.message || "Đổi mật khẩu thất bại");
}
};
if (done) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#063e8e]/5 to-[#f8fbff] p-4">
<div className="w-full max-w-md rounded-3xl border border-[#063e8e]/10 bg-white p-8 text-center shadow-xl">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<h1 className="text-2xl font-bold text-[#163b73]">Đổi mật khẩu thành công</h1>
<p className="mt-2 text-sm text-slate-600">
Mật khẩu của bạn đã được cập nhật. Bạn sẽ được chuyển về trang đăng nhập
để đăng nhập lại bằng mật khẩu mới.
</p>
<div className="mt-6 flex items-center justify-center gap-2 text-sm text-slate-500">
<Loader2 className="h-4 w-4 animate-spin" />
Đang chuyển hướng...
</div>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#063e8e]/5 to-[#f8fbff] p-4">
<div className="w-full max-w-md rounded-3xl border border-[#063e8e]/10 bg-white p-8 shadow-xl">
{/* Header */}
<div className="mb-6 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-100">
<KeyRound className="h-8 w-8 text-amber-600" />
</div>
<h1 className="text-2xl font-bold text-[#163b73]">Đổi mật khẩu</h1>
<p className="mt-2 text-sm text-slate-600">
Đây là lần đầu bạn đăng nhập. Vui lòng đổi mật khẩu để tiếp tục.
</p>
</div>
{/* Warning */}
<div className="mb-6 flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
<p className="text-sm text-amber-800">
Mật khẩu mặc định không được phép sử dụng. Bạn cần tạo mật khẩu mới
để bảo vệ tài khoản.
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label className="text-sm font-medium">Mật khẩu hiện tại *</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type={showOld ? "text" : "password"}
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="Nhập mật khẩu hiện tại"
className="h-11 rounded-xl border-[#063e8e]/15 pl-10 pr-10"
autoFocus
/>
<button
type="button"
onClick={() => setShowOld(!showOld)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showOld ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">Mật khẩu mới *</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type={showNew ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Ít nhất 6 ký tự"
className="h-11 rounded-xl border-[#063e8e]/15 pl-10 pr-10"
/>
<button
type="button"
onClick={() => setShowNew(!showNew)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">Xác nhận mật khẩu mới *</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type={showConfirm ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Nhập lại mật khẩu mới"
className="h-11 rounded-xl border-[#063e8e]/15 pl-10 pr-10"
/>
<button
type="button"
onClick={() => setShowConfirm(!showConfirm)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{confirmPassword && newPassword !== confirmPassword && (
<p className="text-xs text-red-600">Mật khẩu xác nhận không khớp</p>
)}
</div>
<Button
type="submit"
disabled={
!oldPassword ||
!newPassword ||
!confirmPassword ||
newPassword !== confirmPassword ||
changePasswordMutation.isPending
}
className="h-11 w-full rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
{changePasswordMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Đổi mật khẩu
</Button>
</form>
</div>
</div>
);
}
This diff is collapsed.
"use client";
import Link from "next/link";
import { usePermission } from "@/hooks/usePermission";
import { NoPermissionMessage } from "@/components/shared/permission-gate";
export default function NoPermissionPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-[#f6f9ff] via-[#edf4ff] to-[#f8fbff]">
{/* Header */}
<div className="border-b border-[#063e8e]/10 bg-white/80 px-6 py-4 backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#063e8e]">
<svg
className="h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
</div>
<div>
<div className="text-sm font-semibold text-[#063e8e]">VCCI News Admin</div>
<div className="text-xs text-slate-500">Trang quản trị website</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="mx-auto max-w-2xl px-4 py-16">
<NoPermissionMessage />
</div>
{/* Footer */}
<div className="border-t border-[#063e8e]/10 bg-white/50 px-6 py-4 text-center">
<p className="text-sm text-slate-500">© 2026 VCCI HCM</p>
</div>
</div>
);
}
import { redirect } from 'next/navigation'; "use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import useAuthStore from "@/store/useAuthStore";
import { usePermission } from "@/hooks/usePermission";
import { Loader2, ShieldX, Mail, Phone } from "lucide-react";
import { Button } from "@/components/ui/button";
// Thứ tự ưu tiên các trang admin mặc định
const ADMIN_LANDING_ROUTES = [
{ path: "/admin/dashboard", permission: "dashboard:read" as const },
{ path: "/admin/news", permission: "posts:read" as const },
{ path: "/admin/base-config", permission: "settings:read" as const },
{ path: "/admin/users", permission: "users:read" as const },
{ path: "/admin/password-reset-requests", permission: "users:read" as const },
{ path: "/admin/roles", permission: "roles:read" as const },
{ path: "/admin/advertisements", permission: "advertisements:read" as const },
{ path: "/admin/media", permission: "files:read" as const },
{ path: "/admin/tags", permission: "tags:read" as const },
{ path: "/admin/videos", permission: "videos:read" as const },
{ path: "/admin/members", permission: "members:read" as const },
{ path: "/admin/contact-management", permission: "contact:read" as const },
];
export default function AdminPage() { export default function AdminPage() {
redirect('/admin/base-config'); const router = useRouter();
const appUser = useAuthStore((state) => state.appUser);
const hasHydrated = useAuthStore((state) => state._hasHydrated);
const isLoggedIn = useAuthStore((state) => state.appIsLoggedIn);
const hasDashboard = usePermission("dashboard", "read");
const hasPosts = usePermission("posts", "read");
const hasSettings = usePermission("settings", "read");
const hasUsers = usePermission("users", "read");
const hasRoles = usePermission("roles", "read");
const hasAds = usePermission("advertisements", "read");
const hasFiles = usePermission("files", "read");
const hasTags = usePermission("tags", "read");
const hasVideos = usePermission("videos", "read");
const hasMembers = usePermission("members", "read");
const hasContact = usePermission("contact", "read");
const permissionMap: Record<string, boolean> = {
"dashboard:read": hasDashboard,
"posts:read": hasPosts,
"settings:read": hasSettings,
"users:read": hasUsers,
"roles:read": hasRoles,
"advertisements:read": hasAds,
"files:read": hasFiles,
"tags:read": hasTags,
"videos:read": hasVideos,
"members:read": hasMembers,
"contact:read": hasContact,
};
useEffect(() => {
if (!hasHydrated || !isLoggedIn) return;
if (appUser?.must_change_password) return; // AuthGuard sẽ xử lý
// Tìm trang admin đầu tiên user có quyền
const firstAllowed = ADMIN_LANDING_ROUTES.find(
(route) => permissionMap[route.permission],
);
if (firstAllowed) {
router.replace(firstAllowed.path);
}
// Nếu không có quyền gì → stay on /admin, render no-access message
}, [hasHydrated, isLoggedIn, appUser, router]);
// Loading
if (!hasHydrated || !isLoggedIn) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f8fbff]">
<Loader2 className="h-8 w-8 animate-spin text-[#063e8e]" />
</div>
);
}
// Nếu phải đổi mật khẩu → AuthGuard sẽ redirect, tạm render loading
if (appUser?.must_change_password) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f8fbff]">
<Loader2 className="h-8 w-8 animate-spin text-[#063e8e]" />
</div>
);
}
// Kiểm tra có quyền gì không
const hasAnyPermission = ADMIN_LANDING_ROUTES.some(
(route) => permissionMap[route.permission],
);
// Nếu có quyền → loading (đang redirect)
if (hasAnyPermission) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f8fbff]">
<Loader2 className="h-8 w-8 animate-spin text-[#063e8e]" />
</div>
);
}
// Không có quyền gì → hiển thị thông báo
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-b from-[#f6f9ff] via-[#edf4ff] to-[#f8fbff] px-4">
<div className="w-full max-w-2xl rounded-3xl border border-[#063e8e]/10 bg-white p-8 text-center shadow-xl md:p-12">
{/* Icon */}
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-amber-100">
<ShieldX className="h-10 w-10 text-amber-600" />
</div>
{/* Title */}
<h1 className="text-2xl font-bold text-[#163b73] md:text-3xl">
Tài khoản chưa được cấp quyền
</h1>
{/* Message */}
<p className="mx-auto mt-4 max-w-md text-sm text-slate-600 md:text-base">
Tài khoản của bạn hiện tại chưa có quyền quản trị chức năng nào.
Vui lòng liên hệ ban quản trị website hoặc bên kỹ thuật để được hỗ trợ.
</p>
{/* User info */}
<div className="mt-6 rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] px-6 py-4 text-left">
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-2">
<div>
<span className="text-slate-500">Email:</span>{" "}
<span className="font-medium text-[#163b73]">{appUser?.email}</span>
</div>
<div>
<span className="text-slate-500">Vai trò:</span>{" "}
<span className="font-medium text-[#163b73]">
{appUser?.roles?.length ? appUser.roles.join(", ") : "Chưa có"}
</span>
</div>
</div>
</div>
</div>
</div>
);
} }
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { Image as ImageIcon, Plus, Type, Upload, X } from "lucide-react"; import { Image as ImageIcon, Pencil, Plus, Type, Upload, X } from "lucide-react";
import { AdminImagePicker } from "@/components/admin/image-picker"; import { AdminImagePicker } from "@/components/admin/image-picker";
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";
...@@ -25,6 +25,86 @@ interface AdminPostContentEditorProps { ...@@ -25,6 +25,86 @@ interface AdminPostContentEditorProps {
onChange: (sections: AdminNewsContentSection[]) => void; onChange: (sections: AdminNewsContentSection[]) => void;
} }
function CaptionEditor({
caption,
onCaptionChange,
}: {
caption: string;
onCaptionChange: (caption: string) => void;
}) {
const [isEditing, setIsEditing] = React.useState(false);
const [value, setValue] = React.useState(caption);
React.useEffect(() => {
setValue(caption);
}, [caption]);
const handleSave = () => {
onCaptionChange(value.trim());
setIsEditing(false);
};
const handleCancel = () => {
setValue(caption);
setIsEditing(false);
};
if (isEditing) {
return (
<div className="flex flex-col gap-2 p-2">
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") handleCancel();
}}
placeholder="Nhập chú thích cho ảnh..."
className="w-full rounded-lg border border-[#063e8e]/15 bg-white px-3 py-2 text-sm text-gray-700 placeholder:text-gray-500 focus:border-[#063e8e]/30 focus:outline-none focus:ring-2 focus:ring-[#063e8e]/20"
autoFocus
/>
<div className="flex gap-2">
<button
type="button"
onClick={handleSave}
className="flex-1 rounded-lg bg-[#063e8e] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#063e8e]/90"
>
Lưu
</button>
<button
type="button"
onClick={handleCancel}
className="flex-1 rounded-lg border border-[#063e8e]/15 px-3 py-1.5 text-xs font-medium text-gray-700 transition hover:bg-[#063e8e]/5"
>
Hủy
</button>
</div>
</div>
);
}
return (
<div className="group/piece flex min-h-11 items-center justify-between gap-2 border-t border-[#063e8e]/10 px-3 py-2">
{caption ? (
<p className="flex-1 text-center text-xs italic text-gray-700">{caption}</p>
) : (
<p className="flex-1 text-center text-xs italic text-gray-500">
Thêm chú thích...
</p>
)}
<button
type="button"
onClick={() => setIsEditing(true)}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-gray-400 transition hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
title="Sửa chú thích"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
);
}
export function AdminPostContentEditor({ export function AdminPostContentEditor({
sections, sections,
onChange, onChange,
...@@ -98,6 +178,7 @@ export function AdminPostContentEditor({ ...@@ -98,6 +178,7 @@ export function AdminPostContentEditor({
const nextImages = section.images.filter((image) => image.position !== pickerState.position); const nextImages = section.images.filter((image) => image.position !== pickerState.position);
nextImages.push({ nextImages.push({
position: pickerState.position, position: pickerState.position,
caption: "",
image: { image: {
id: item.id, id: item.id,
name: item.name, name: item.name,
...@@ -295,10 +376,16 @@ export function AdminPostContentEditor({ ...@@ -295,10 +376,16 @@ export function AdminPostContentEditor({
const currentImage = section.images.find( const currentImage = section.images.find(
(image) => image.position === position, (image) => image.position === position,
); );
const currentImageId = currentImage?.image?.id ?? null;
return ( return (
<div <div
key={`${section.id}-${position}`} key={`${section.id}-${position}`}
className="group flex flex-col overflow-hidden rounded-2xl border border-dashed border-[#063e8e]/20 bg-white transition hover:border-[#063e8e]/40"
>
{currentImage ? (
<>
<div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => onClick={() =>
...@@ -306,7 +393,7 @@ export function AdminPostContentEditor({ ...@@ -306,7 +393,7 @@ export function AdminPostContentEditor({
open: true, open: true,
sectionId: section.id, sectionId: section.id,
position, position,
selectedId: currentImage?.image.id ?? null, selectedId: currentImageId,
}) })
} }
onKeyDown={(event) => { onKeyDown={(event) => {
...@@ -316,14 +403,12 @@ export function AdminPostContentEditor({ ...@@ -316,14 +403,12 @@ export function AdminPostContentEditor({
open: true, open: true,
sectionId: section.id, sectionId: section.id,
position, position,
selectedId: currentImage?.image.id ?? null, selectedId: currentImageId,
}); });
} }
}} }}
className="group relative flex aspect-square cursor-pointer items-center justify-center overflow-hidden rounded-2xl border border-dashed border-[#063e8e]/20 bg-white text-center transition hover:border-[#063e8e]/40" className="relative h-64 w-full cursor-pointer"
> >
{currentImage ? (
<>
<SafeNextImage <SafeNextImage
src={currentImage.image.url} src={currentImage.image.url}
alt={currentImage.image.alt || currentImage.image.name} alt={currentImage.image.alt || currentImage.image.name}
...@@ -340,14 +425,53 @@ export function AdminPostContentEditor({ ...@@ -340,14 +425,53 @@ export function AdminPostContentEditor({
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
</div>
<div onClick={(e) => e.stopPropagation()}>
<CaptionEditor
caption={currentImage.caption}
onCaptionChange={(caption) => {
updateSection(section.id, (sec) => ({
...sec,
images: sec.images.map((img) =>
img.position === position ? { ...img, caption } : img
),
}));
}}
/>
</div>
</> </>
) : ( ) : (
<div className="px-3 text-center"> <div
role="button"
tabIndex={0}
onClick={() =>
setPickerState({
open: true,
sectionId: section.id,
position,
selectedId: currentImageId,
})
}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setPickerState({
open: true,
sectionId: section.id,
position,
selectedId: currentImageId,
});
}
}}
className="flex h-40 w-full shrink-0 cursor-pointer items-center justify-center px-3 text-center"
>
<div>
<Upload className="mx-auto mb-2 h-5 w-5 text-[#063e8e]" /> <Upload className="mx-auto mb-2 h-5 w-5 text-[#063e8e]" />
<p className="text-xs font-medium text-gray-700"> <p className="text-xs font-medium text-gray-700">
Chọn ảnh {position} Chọn ảnh {position}
</p> </p>
</div> </div>
</div>
)} )}
</div> </div>
); );
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -36,6 +36,7 @@ export interface CmsFileItem { ...@@ -36,6 +36,7 @@ export interface CmsFileItem {
export interface CmsPostContentImage { export interface CmsPostContentImage {
position: number; position: number;
caption: string;
image: { image: {
id: string; id: string;
name: string; name: string;
...@@ -277,6 +278,7 @@ const parsePostContent = (contentStructure?: Record<string, unknown> | null): Cm ...@@ -277,6 +278,7 @@ const parsePostContent = (contentStructure?: Record<string, unknown> | null): Cm
image_rows: typeof section.image_rows === "number" ? section.image_rows : 2, image_rows: typeof section.image_rows === "number" ? section.image_rows : 2,
images: images.map((image, imageIndex) => ({ images: images.map((image, imageIndex) => ({
position: typeof image.position === "number" ? image.position : imageIndex + 1, position: typeof image.position === "number" ? image.position : imageIndex + 1,
caption: typeof image.caption === "string" ? image.caption : "",
image: { image: {
id: typeof image.image === "object" && image.image && "id" in image.image id: typeof image.image === "object" && image.image && "id" in image.image
? String((image.image as Record<string, unknown>).id ?? "") ? String((image.image as Record<string, unknown>).id ?? "")
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -11,6 +11,7 @@ export interface AuthenticatedAdminUser { ...@@ -11,6 +11,7 @@ export interface AuthenticatedAdminUser {
permissions: string[]; permissions: string[];
status: string | null; status: string | null;
last_login_at: string | null; last_login_at: string | null;
must_change_password?: boolean;
} }
export interface AuthenticatedAdminSession { export interface AuthenticatedAdminSession {
......
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