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
+1 -1
View File
@@ -1,4 +1,4 @@
NEXT_PUBLIC_APP_VERSION=2.2.6 NEXT_PUBLIC_APP_VERSION=2.2.7
NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500 NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0 NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com
+36 -12
View File
@@ -3,10 +3,11 @@ import { TrashIcon } from "@/assets/icons";
import InputGroup from "@/components/FormElements/InputGroup"; import InputGroup from "@/components/FormElements/InputGroup";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area"; import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import { ShowcaseSection } from "@/components/Layouts/showcase-section"; import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import { REGEX } from "@/constants/regex"; import ImageUploadField from "@/components/ui/ImageUploadField";
import { FormErrorMessages } from "@/constants/Texts"; import { FormErrorMessages } from "@/constants/Texts";
import { import {
Control, Control,
Controller,
FieldErrors, FieldErrors,
FieldValues, FieldValues,
Path, Path,
@@ -16,6 +17,20 @@ import {
UseFormRegister, UseFormRegister,
} from "react-hook-form"; } from "react-hook-form";
const ICON_ACCEPT =
"image/png,image/jpeg,image/jpg,image/webp,image/svg+xml,image/gif";
const ICON_ALLOWED_TYPES = [
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/svg+xml",
"image/gif",
];
const slugify = (value: string) =>
value.toLowerCase().replace(/[^a-z0-9]+/g, "-");
interface IProps<T extends FieldValues> { interface IProps<T extends FieldValues> {
title: string; title: string;
fields: any[]; fields: any[];
@@ -39,6 +54,7 @@ const SectionStep = <T extends FieldValues>({
errors, errors,
append, append,
remove, remove,
control,
fieldName, fieldName,
defaultValues, defaultValues,
}: IProps<T>) => { }: IProps<T>) => {
@@ -82,22 +98,30 @@ const SectionStep = <T extends FieldValues>({
key={field.id} key={field.id}
className="grid grid-cols-1 gap-4 sm:grid-cols-2" className="grid grid-cols-1 gap-4 sm:grid-cols-2"
> >
<InputGroup <Controller
{...register(`cards.${index}.icon` as Path<T>, { control={control}
name={`cards.${index}.icon` as Path<T>}
rules={{
required: { required: {
message: FormErrorMessages.required, message: FormErrorMessages.required,
value: true, value: true,
}, },
pattern: { }}
message: FormErrorMessages.invalidPattern, render={({ field, fieldState }) => (
value: REGEX.URL, <ImageUploadField
}, value={(field.value as string) ?? ""}
})} onChange={field.onChange}
label="Icon" label="Icon"
name={`cards.${index}.icon`} inputId={`${slugify(title)}-card-icon-${index}`}
required title="Click to upload an icon"
type="text" hint="PNG, JPG, WEBP, SVG, GIF up to 5MB"
placeholder="Enter Icon URL" recommendation="Recommended size: 128 x 128"
fallbackFileName="Current icon"
accept={ICON_ACCEPT}
allowedTypes={ICON_ALLOWED_TYPES}
errorMessage={fieldState.error?.message as string}
/>
)}
/> />
<InputGroup <InputGroup
{...register(`cards.${index}.title` as Path<T>, { {...register(`cards.${index}.title` as Path<T>, {
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui-elements/button";
import CollapsibleFilterSection from "@/components/ui/CollapsibleFilterSection"; import CollapsibleFilterSection from "@/components/ui/CollapsibleFilterSection";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel"; import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { Countries } from "@/constants/countries"; import { Countries } from "@/constants/countries";
import { isDateRangeActive } from "@/utils/shared";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
Control, Control,
@@ -37,15 +38,6 @@ const RANGE_KEYS = [
"submission_deadline_range", "submission_deadline_range",
] as const; ] 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 { function countActiveFilters(values: Record<string, unknown>): number {
let n = 0; let n = 0;
const textKeys = [ const textKeys = [
@@ -66,7 +58,7 @@ function countActiveFilters(values: Record<string, unknown>): number {
const country = values.country; const country = values.country;
if (typeof country === "string" && country.trim()) n++; if (typeof country === "string" && country.trim()) n++;
for (const key of RANGE_KEYS) { for (const key of RANGE_KEYS) {
if (isRangeActive(values[key])) n++; if (isDateRangeActive(values[key])) n++;
} }
if (values.documents_scraped === true) n++; if (values.documents_scraped === true) n++;
return n; return n;
@@ -9,6 +9,7 @@ import {
buildQueryString, buildQueryString,
deleteEmptyKeys, deleteEmptyKeys,
getPaginatedRowNumber, getPaginatedRowNumber,
isDateRangeActive,
unixToDate, unixToDate,
} from "@/utils/shared"; } from "@/utils/shared";
import gsap from "gsap"; import gsap from "gsap";
@@ -293,6 +294,40 @@ const TENDER_FILTER_MODAL_KEYS = new Set<string>([
...TENDER_RANGE_PARAM_KEYS, ...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 useTenderListPresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [currentTender, setCurrentTender] = useState<TCustomer | null>(null); const [currentTender, setCurrentTender] = useState<TCustomer | null>(null);
@@ -482,7 +517,22 @@ const useTenderListPresenter = () => {
delete merged[key]; 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 cleanedParams = deleteEmptyKeys(newParams);
const queryString = buildQueryString(cleanedParams); const queryString = buildQueryString(cleanedParams);
router.push(`${pathName}?${queryString}`); router.push(`${pathName}?${queryString}`);
+12 -4
View File
@@ -8,6 +8,10 @@ interface FileImageUploadCardProps {
label: string; label: string;
inputId: string; inputId: string;
accept?: string; accept?: string;
title?: string;
hint?: string;
recommendation?: string;
fallbackFileName?: string;
previewUrl?: string | null; previewUrl?: string | null;
selectedFileName?: string; selectedFileName?: string;
selectedFileSize?: number | null; selectedFileSize?: number | null;
@@ -22,6 +26,10 @@ const FileImageUploadCard = ({
label, label,
inputId, inputId,
accept = "image/png,image/jpeg,image/jpg,image/webp", 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, previewUrl,
selectedFileName, selectedFileName,
selectedFileSize, selectedFileSize,
@@ -68,11 +76,11 @@ const FileImageUploadCard = ({
</svg> </svg>
</div> </div>
<p className="text-sm font-semibold text-dark dark:text-white"> <p className="text-sm font-semibold text-dark dark:text-white">
Click to upload your profile image {title}
</p> </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]"> <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> </div>
</label> </label>
) : ( ) : (
@@ -97,7 +105,7 @@ const FileImageUploadCard = ({
Ready Ready
</div> </div>
<p className="truncate text-sm font-semibold text-dark dark:text-white"> <p className="truncate text-sm font-semibold text-dark dark:text-white">
{selectedFileName || "Current profile image"} {selectedFileName || fallbackFileName}
</p> </p>
{selectedFileSize ? ( {selectedFileSize ? (
<p className="mt-0.5 text-xs text-dark-5"> <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,
};
};
+59
View File
@@ -0,0 +1,59 @@
import api from "../axios";
import { buildFileDownloadUrl } from "../config";
import { API_ENDPOINTS } from "../endpoints";
interface IUploadFileParams {
file: File;
category?: string;
tags?: string[];
description?: string;
onProgress?: (percent: number) => void;
}
export const fileService = {
/**
* Uploads a binary file to the shared filestore endpoint and returns the
* absolute, directly-loadable download URL for the stored file.
*
* Mirrors the two-step flow documented for CMS/marketing media: upload the
* file, then persist the resulting URL on the entity (e.g. `card.icon`).
*/
uploadFile: async ({
file,
category,
tags = [],
description,
onProgress,
}: IUploadFileParams): Promise<string> => {
try {
const formData = new FormData();
formData.append("file", file);
if (category) formData.append("category", category);
tags.forEach((tag) => formData.append("tags", tag));
if (description) formData.append("description", description);
const response = await api.post(API_ENDPOINTS.FILES.UPLOAD, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress: (progressEvent) => {
if (!progressEvent.total) return;
const percent = Math.round(
(progressEvent.loaded * 100) / progressEvent.total,
);
onProgress?.(percent);
},
});
const fileId = response?.data?.file_id as string | undefined;
if (!fileId) {
throw new Error("No file id returned from upload");
}
return buildFileDownloadUrl(fileId);
} catch (error) {
console.error("ERROR caught in File Service Upload File", error);
throw error;
}
},
};
+1
View File
@@ -1,5 +1,6 @@
export * from "./companies-service"; export * from "./companies-service";
export * from "./dashboard-service"; export * from "./dashboard-service";
export * from "./file-service";
export * from "./inquiries-service"; export * from "./inquiries-service";
export * from "./profile-service"; export * from "./profile-service";
export * from "./tenders-service"; export * from "./tenders-service";
+9 -27
View File
@@ -1,6 +1,7 @@
import api from "../axios"; import api from "../axios";
import { buildFileDownloadUrl, extractFileId } from "../config"; import { extractFileId } from "../config";
import { API_ENDPOINTS } from "../endpoints"; import { API_ENDPOINTS } from "../endpoints";
import { fileService } from "./file-service";
import { import {
ApiResponse, ApiResponse,
IAdminListResponse, IAdminListResponse,
@@ -128,34 +129,15 @@ export const userService = {
onProgress?: (percent: number) => void; onProgress?: (percent: number) => void;
}) => { }) => {
try { try {
const formData = new FormData();
formData.append("file", file);
formData.append("category", "profile_images");
formData.append("tags", "user");
formData.append("tags", "profile");
formData.append("description", "Admin profile image");
const response = await api.post(API_ENDPOINTS.FILES.UPLOAD, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress: (progressEvent) => {
if (!progressEvent.total) return;
const percent = Math.round(
(progressEvent.loaded * 100) / progressEvent.total,
);
onProgress?.(percent);
},
});
const fileId = response?.data?.file_id as string | undefined;
if (!fileId) {
throw new Error("No file id returned from upload");
}
// Persisted on the backend as the profile image, so it must be an // Persisted on the backend as the profile image, so it must be an
// absolute, directly-loadable URL — not the frontend `/api/proxy` path. // absolute, directly-loadable URL — not the frontend `/api/proxy` path.
return buildFileDownloadUrl(fileId); return fileService.uploadFile({
file,
category: "profile_images",
tags: ["user", "profile"],
description: "Admin profile image",
onProgress,
});
} catch (error) { } catch (error) {
console.error("ERROR caught in User Service Upload Profile Image", error); console.error("ERROR caught in User Service Upload Profile Image", error);
throw error; throw error;
+10
View File
@@ -139,6 +139,16 @@ export const buildQueryString = (params: Record<string, any>): string => {
export const serializeAxiosParams = buildQueryString; export const serializeAxiosParams = buildQueryString;
export const isValidAlpha2CountryCode = (code: string) => /^[A-Za-z]{2}$/.test(code); export const isValidAlpha2CountryCode = (code: string) => /^[A-Za-z]{2}$/.test(code);
/** True when a date-range filter value holds at least one usable (positive, finite) unix bound. */
export const isDateRangeActive = (value: unknown): boolean => {
if (!Array.isArray(value) || value.length === 0) return false;
return value.some((bound) => {
if (bound === undefined || bound === null || bound === "") return false;
const n = typeof bound === "number" ? bound : Number(bound);
return Number.isFinite(n) && n > 0;
});
};
export const getPaginatedRowNumber = ({ export const getPaginatedRowNumber = ({
index, index,
page = 1, page = 1,