chore(env): update application version to 2.2.7 in production environment
feat(image-upload): introduce ImageUploadField component for enhanced image uploads - Added ImageUploadField component to streamline image uploads with improved UI and functionality. - Integrated file upload handling with a new file service for better management of image uploads. - Updated SectionStep component to utilize ImageUploadField for icon uploads, enhancing user experience. - Refactored TenderListFilters to use isDateRangeActive utility for improved date range filtering logic.
This commit is contained in:
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui-elements/button";
|
||||
import CollapsibleFilterSection from "@/components/ui/CollapsibleFilterSection";
|
||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||
import { Countries } from "@/constants/countries";
|
||||
import { isDateRangeActive } from "@/utils/shared";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Control,
|
||||
@@ -37,15 +38,6 @@ const RANGE_KEYS = [
|
||||
"submission_deadline_range",
|
||||
] as const;
|
||||
|
||||
function isRangeActive(value: unknown): boolean {
|
||||
if (!Array.isArray(value) || value.length === 0) return false;
|
||||
return value.some((x) => {
|
||||
if (x === undefined || x === null || x === "") return false;
|
||||
const n = typeof x === "number" ? x : Number(x);
|
||||
return Number.isFinite(n) && n > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function countActiveFilters(values: Record<string, unknown>): number {
|
||||
let n = 0;
|
||||
const textKeys = [
|
||||
@@ -66,7 +58,7 @@ function countActiveFilters(values: Record<string, unknown>): number {
|
||||
const country = values.country;
|
||||
if (typeof country === "string" && country.trim()) n++;
|
||||
for (const key of RANGE_KEYS) {
|
||||
if (isRangeActive(values[key])) n++;
|
||||
if (isDateRangeActive(values[key])) n++;
|
||||
}
|
||||
if (values.documents_scraped === true) n++;
|
||||
return n;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildQueryString,
|
||||
deleteEmptyKeys,
|
||||
getPaginatedRowNumber,
|
||||
isDateRangeActive,
|
||||
unixToDate,
|
||||
} from "@/utils/shared";
|
||||
import gsap from "gsap";
|
||||
@@ -293,6 +294,40 @@ const TENDER_FILTER_MODAL_KEYS = new Set<string>([
|
||||
...TENDER_RANGE_PARAM_KEYS,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Applying a date-range filter retargets the table sort to that same field, so the
|
||||
* results lead with the dates the user just narrowed to. Ordered by precedence — the
|
||||
* first active range wins when several are applied at once. `sortOrder` mirrors each
|
||||
* column's default click direction (see `columnDefaultOrder`).
|
||||
*/
|
||||
const SORT_TARGET_BY_RANGE_KEY: Array<{
|
||||
rangeKey: string;
|
||||
sortBy: string;
|
||||
sortOrder: SortDirection;
|
||||
}> = [
|
||||
{
|
||||
rangeKey: "publication_date_range",
|
||||
sortBy: "publication_date",
|
||||
sortOrder: "desc",
|
||||
},
|
||||
{
|
||||
rangeKey: "submission_deadline_range",
|
||||
sortBy: "submission_deadline",
|
||||
sortOrder: "asc",
|
||||
},
|
||||
{
|
||||
rangeKey: "tender_deadline_range",
|
||||
sortBy: "tender_deadline",
|
||||
sortOrder: "asc",
|
||||
},
|
||||
{ rangeKey: "created_at_range", sortBy: "created_at", sortOrder: "desc" },
|
||||
];
|
||||
|
||||
const resolveSortFromActiveDateFilters = (formData: Record<string, any>) =>
|
||||
SORT_TARGET_BY_RANGE_KEY.find((target) =>
|
||||
isDateRangeActive(formData[target.rangeKey]),
|
||||
) ?? null;
|
||||
|
||||
const useTenderListPresenter = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [currentTender, setCurrentTender] = useState<TCustomer | null>(null);
|
||||
@@ -482,7 +517,22 @@ const useTenderListPresenter = () => {
|
||||
delete merged[key];
|
||||
}
|
||||
}
|
||||
const newParams = buildFirstPageParams({ ...merged, ...normalizedData });
|
||||
|
||||
// Retarget the sort to a freshly applied date filter, but only while the user is
|
||||
// on the implicit default sort — never clobber a column they sorted on by hand.
|
||||
const sortTarget = resolveSortFromActiveDateFilters(data);
|
||||
const currentSortBy = params.sort_by as string | undefined;
|
||||
const isDefaultSort = !currentSortBy || currentSortBy === "created_at";
|
||||
const sortOverride =
|
||||
sortTarget && isDefaultSort
|
||||
? { sort_by: sortTarget.sortBy, sort_order: sortTarget.sortOrder }
|
||||
: {};
|
||||
|
||||
const newParams = buildFirstPageParams({
|
||||
...merged,
|
||||
...normalizedData,
|
||||
...sortOverride,
|
||||
});
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
const queryString = buildQueryString(cleanedParams);
|
||||
router.push(`${pathName}?${queryString}`);
|
||||
|
||||
@@ -8,6 +8,10 @@ interface FileImageUploadCardProps {
|
||||
label: string;
|
||||
inputId: string;
|
||||
accept?: string;
|
||||
title?: string;
|
||||
hint?: string;
|
||||
recommendation?: string;
|
||||
fallbackFileName?: string;
|
||||
previewUrl?: string | null;
|
||||
selectedFileName?: string;
|
||||
selectedFileSize?: number | null;
|
||||
@@ -22,6 +26,10 @@ const FileImageUploadCard = ({
|
||||
label,
|
||||
inputId,
|
||||
accept = "image/png,image/jpeg,image/jpg,image/webp",
|
||||
title = "Click to upload your profile image",
|
||||
hint = "PNG, JPG, WEBP up to 5MB",
|
||||
recommendation = "Recommended size: 400 x 400",
|
||||
fallbackFileName = "Current profile image",
|
||||
previewUrl,
|
||||
selectedFileName,
|
||||
selectedFileSize,
|
||||
@@ -68,11 +76,11 @@ const FileImageUploadCard = ({
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-dark dark:text-white">
|
||||
Click to upload your profile image
|
||||
{title}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-dark-5">PNG, JPG, WEBP up to 5MB</p>
|
||||
<p className="mt-1 text-xs text-dark-5">{hint}</p>
|
||||
<div className="mt-4 inline-flex items-center rounded-full border border-primary/20 bg-primary/[0.08] px-3 py-1 text-[11px] font-medium text-primary dark:border-primary/40 dark:bg-primary/[0.18]">
|
||||
Recommended size: 400 x 400
|
||||
{recommendation}
|
||||
</div>
|
||||
</label>
|
||||
) : (
|
||||
@@ -97,7 +105,7 @@ const FileImageUploadCard = ({
|
||||
Ready
|
||||
</div>
|
||||
<p className="truncate text-sm font-semibold text-dark dark:text-white">
|
||||
{selectedFileName || "Current profile image"}
|
||||
{selectedFileName || fallbackFileName}
|
||||
</p>
|
||||
{selectedFileSize ? (
|
||||
<p className="mt-0.5 text-xs text-dark-5">
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import FileImageUploadCard from "@/components/ui/FileImageUploadCard";
|
||||
import { useImageUploadFieldPresenter } from "@/components/ui/useImageUploadFieldPresenter";
|
||||
|
||||
interface IProps {
|
||||
value?: string;
|
||||
onChange: (url: string) => void;
|
||||
label: string;
|
||||
inputId: string;
|
||||
accept?: string;
|
||||
title?: string;
|
||||
hint?: string;
|
||||
recommendation?: string;
|
||||
fallbackFileName?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
allowedTypes?: string[];
|
||||
maxSize?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlled image upload field: renders the shared `FileImageUploadCard` and
|
||||
* owns the upload lifecycle via `useImageUploadFieldPresenter`. Stores the
|
||||
* resulting file URL through `onChange`.
|
||||
*/
|
||||
const ImageUploadField = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
inputId,
|
||||
accept,
|
||||
title,
|
||||
hint,
|
||||
recommendation,
|
||||
fallbackFileName,
|
||||
category,
|
||||
tags,
|
||||
description,
|
||||
allowedTypes,
|
||||
maxSize,
|
||||
errorMessage,
|
||||
}: IProps) => {
|
||||
const {
|
||||
fileRegister,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
selectedFileName,
|
||||
selectedFileSize,
|
||||
previewUrl,
|
||||
errorMessage: uploadError,
|
||||
clear,
|
||||
} = useImageUploadFieldPresenter({
|
||||
value,
|
||||
onChange,
|
||||
category,
|
||||
tags,
|
||||
description,
|
||||
allowedTypes,
|
||||
maxSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<FileImageUploadCard
|
||||
label={label}
|
||||
inputId={inputId}
|
||||
accept={accept}
|
||||
title={title}
|
||||
hint={hint}
|
||||
recommendation={recommendation}
|
||||
fallbackFileName={fallbackFileName}
|
||||
previewUrl={previewUrl}
|
||||
selectedFileName={selectedFileName}
|
||||
selectedFileSize={selectedFileSize}
|
||||
isLoading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
errorMessage={uploadError ?? errorMessage}
|
||||
onClear={clear}
|
||||
fileRegister={fileRegister}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploadField;
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { fileService } from "@/lib/api";
|
||||
import { extractFileId } from "@/lib/api/config";
|
||||
import { userService } from "@/lib/api/services/user-services";
|
||||
import {
|
||||
ALLOWED_IMAGE_FILE_TYPES,
|
||||
MAX_IMAGE_FILE_SIZE,
|
||||
validateImageFile,
|
||||
} from "@/utils/shared";
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
interface IProps {
|
||||
/** Currently stored URL for this field (empty string when none). */
|
||||
value?: string;
|
||||
/** Persist the uploaded file URL (or empty string when cleared). */
|
||||
onChange: (url: string) => void;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
allowedTypes?: string[];
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
interface IFileFieldValues {
|
||||
file?: FileList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable upload lifecycle for a single image field that stores a URL string.
|
||||
* Generalizes the profile-image flow from `useCreateAdminPresenter` so any form
|
||||
* (e.g. marketing card icons) can drop in a `FileImageUploadCard`.
|
||||
*/
|
||||
export const useImageUploadFieldPresenter = ({
|
||||
value,
|
||||
onChange,
|
||||
category = "cms",
|
||||
tags = ["cms", "marketing"],
|
||||
description = "Marketing media",
|
||||
allowedTypes = ALLOWED_IMAGE_FILE_TYPES,
|
||||
maxSize = MAX_IMAGE_FILE_SIZE,
|
||||
}: IProps) => {
|
||||
const [selectedFileName, setSelectedFileName] = useState<string>("");
|
||||
const [selectedFileSize, setSelectedFileSize] = useState<number | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<number>(0);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>();
|
||||
|
||||
const { register, setValue } = useForm<IFileFieldValues>({
|
||||
defaultValues: { file: undefined },
|
||||
});
|
||||
|
||||
// Build a previewable source from the stored value. Backend file URLs are
|
||||
// auth-protected, so fetch them through the proxy into a local object URL.
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setPreviewUrl(null);
|
||||
return;
|
||||
}
|
||||
if (!extractFileId(value)) {
|
||||
setPreviewUrl(value);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
userService
|
||||
.fetchFileObjectUrl(value)
|
||||
.then((objectUrl) => {
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
return;
|
||||
}
|
||||
setPreviewUrl(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave the preview empty rather than showing a broken image.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
// Revoke local blob previews when they are replaced or unmounted.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
const resetSelection = () => {
|
||||
setSelectedFileName("");
|
||||
setSelectedFileSize(null);
|
||||
setUploadProgress(0);
|
||||
};
|
||||
|
||||
const onFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
const input = event.target;
|
||||
|
||||
if (!file) {
|
||||
resetSelection();
|
||||
setErrorMessage(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateImageFile({ file, allowedTypes, maxSize });
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError);
|
||||
input.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(undefined);
|
||||
setSelectedFileName(file.name);
|
||||
setSelectedFileSize(file.size);
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
// Preview the local file directly while the upload runs.
|
||||
setPreviewUrl(URL.createObjectURL(file));
|
||||
|
||||
try {
|
||||
const url = await fileService.uploadFile({
|
||||
file,
|
||||
category,
|
||||
tags,
|
||||
description,
|
||||
onProgress: setUploadProgress,
|
||||
});
|
||||
setUploadProgress(100);
|
||||
onChange(url);
|
||||
} catch {
|
||||
resetSelection();
|
||||
setPreviewUrl(null);
|
||||
setValue("file", undefined);
|
||||
onChange("");
|
||||
setErrorMessage("Failed to upload image");
|
||||
input.value = "";
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setValue("file", undefined);
|
||||
resetSelection();
|
||||
setPreviewUrl(null);
|
||||
setErrorMessage(undefined);
|
||||
onChange("");
|
||||
};
|
||||
|
||||
const fileRegister = register("file", { onChange: onFileChange });
|
||||
|
||||
return {
|
||||
fileRegister,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
selectedFileName,
|
||||
selectedFileSize,
|
||||
previewUrl,
|
||||
errorMessage,
|
||||
clear,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user