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:
AmirReza Jamali
2026-06-06 09:51:32 +03:30
parent a99f86b2f9
commit 5d01d66ea3
11 changed files with 436 additions and 56 deletions
@@ -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}`);
+12 -4
View File
@@ -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">
+86
View File
@@ -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,
};
};