From 5d01d66ea341080af169dc024e1ea810cce9db82 Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Sat, 6 Jun 2026 09:51:32 +0330 Subject: [PATCH] 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. --- .env.production | 2 +- src/app/marketing/_components/SectionStep.tsx | 50 ++++-- .../Tables/tenders/TenderListFilters.tsx | 12 +- .../Tables/tenders/useTenderListPresenter.ts | 52 +++++- src/components/ui/FileImageUploadCard.tsx | 16 +- src/components/ui/ImageUploadField.tsx | 86 +++++++++ .../ui/useImageUploadFieldPresenter.ts | 168 ++++++++++++++++++ src/lib/api/services/file-service.ts | 59 ++++++ src/lib/api/services/index.ts | 1 + src/lib/api/services/user-services.ts | 36 +--- src/utils/shared.ts | 10 ++ 11 files changed, 436 insertions(+), 56 deletions(-) create mode 100644 src/components/ui/ImageUploadField.tsx create mode 100644 src/components/ui/useImageUploadFieldPresenter.ts create mode 100644 src/lib/api/services/file-service.ts diff --git a/.env.production b/.env.production index 9c94ade..f09122a 100644 --- a/.env.production +++ b/.env.production @@ -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_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0 NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com diff --git a/src/app/marketing/_components/SectionStep.tsx b/src/app/marketing/_components/SectionStep.tsx index f5d59b1..fffe156 100644 --- a/src/app/marketing/_components/SectionStep.tsx +++ b/src/app/marketing/_components/SectionStep.tsx @@ -3,10 +3,11 @@ import { TrashIcon } from "@/assets/icons"; import InputGroup from "@/components/FormElements/InputGroup"; import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area"; import { ShowcaseSection } from "@/components/Layouts/showcase-section"; -import { REGEX } from "@/constants/regex"; +import ImageUploadField from "@/components/ui/ImageUploadField"; import { FormErrorMessages } from "@/constants/Texts"; import { Control, + Controller, FieldErrors, FieldValues, Path, @@ -16,6 +17,20 @@ import { UseFormRegister, } 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 { title: string; fields: any[]; @@ -39,6 +54,7 @@ const SectionStep = ({ errors, append, remove, + control, fieldName, defaultValues, }: IProps) => { @@ -82,22 +98,30 @@ const SectionStep = ({ key={field.id} className="grid grid-cols-1 gap-4 sm:grid-cols-2" > - , { + } + rules={{ required: { message: FormErrorMessages.required, value: true, }, - pattern: { - message: FormErrorMessages.invalidPattern, - value: REGEX.URL, - }, - })} - label="Icon" - name={`cards.${index}.icon`} - required - type="text" - placeholder="Enter Icon URL" + }} + render={({ field, fieldState }) => ( + + )} /> , { diff --git a/src/components/Tables/tenders/TenderListFilters.tsx b/src/components/Tables/tenders/TenderListFilters.tsx index a74a11f..4ef8646 100644 --- a/src/components/Tables/tenders/TenderListFilters.tsx +++ b/src/components/Tables/tenders/TenderListFilters.tsx @@ -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): number { let n = 0; const textKeys = [ @@ -66,7 +58,7 @@ function countActiveFilters(values: Record): 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; diff --git a/src/components/Tables/tenders/useTenderListPresenter.ts b/src/components/Tables/tenders/useTenderListPresenter.ts index 447967c..c283a20 100644 --- a/src/components/Tables/tenders/useTenderListPresenter.ts +++ b/src/components/Tables/tenders/useTenderListPresenter.ts @@ -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([ ...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) => + SORT_TARGET_BY_RANGE_KEY.find((target) => + isDateRangeActive(formData[target.rangeKey]), + ) ?? null; + const useTenderListPresenter = () => { const [isModalOpen, setIsModalOpen] = useState(false); const [currentTender, setCurrentTender] = useState(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}`); diff --git a/src/components/ui/FileImageUploadCard.tsx b/src/components/ui/FileImageUploadCard.tsx index 10f4e4e..09dd30f 100644 --- a/src/components/ui/FileImageUploadCard.tsx +++ b/src/components/ui/FileImageUploadCard.tsx @@ -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 = ({

- Click to upload your profile image + {title}

-

PNG, JPG, WEBP up to 5MB

+

{hint}

- Recommended size: 400 x 400 + {recommendation}
) : ( @@ -97,7 +105,7 @@ const FileImageUploadCard = ({ Ready

- {selectedFileName || "Current profile image"} + {selectedFileName || fallbackFileName}

{selectedFileSize ? (

diff --git a/src/components/ui/ImageUploadField.tsx b/src/components/ui/ImageUploadField.tsx new file mode 100644 index 0000000..25e3eee --- /dev/null +++ b/src/components/ui/ImageUploadField.tsx @@ -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 ( + + ); +}; + +export default ImageUploadField; diff --git a/src/components/ui/useImageUploadFieldPresenter.ts b/src/components/ui/useImageUploadFieldPresenter.ts new file mode 100644 index 0000000..049fbc8 --- /dev/null +++ b/src/components/ui/useImageUploadFieldPresenter.ts @@ -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(""); + const [selectedFileSize, setSelectedFileSize] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [previewUrl, setPreviewUrl] = useState(null); + const [errorMessage, setErrorMessage] = useState(); + + const { register, setValue } = useForm({ + 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) => { + 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, + }; +}; diff --git a/src/lib/api/services/file-service.ts b/src/lib/api/services/file-service.ts new file mode 100644 index 0000000..4d5398a --- /dev/null +++ b/src/lib/api/services/file-service.ts @@ -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 => { + 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; + } + }, +}; diff --git a/src/lib/api/services/index.ts b/src/lib/api/services/index.ts index 23d11a4..c502c7a 100644 --- a/src/lib/api/services/index.ts +++ b/src/lib/api/services/index.ts @@ -1,5 +1,6 @@ export * from "./companies-service"; export * from "./dashboard-service"; +export * from "./file-service"; export * from "./inquiries-service"; export * from "./profile-service"; export * from "./tenders-service"; diff --git a/src/lib/api/services/user-services.ts b/src/lib/api/services/user-services.ts index f2d20f6..33d601a 100644 --- a/src/lib/api/services/user-services.ts +++ b/src/lib/api/services/user-services.ts @@ -1,6 +1,7 @@ import api from "../axios"; -import { buildFileDownloadUrl, extractFileId } from "../config"; +import { extractFileId } from "../config"; import { API_ENDPOINTS } from "../endpoints"; +import { fileService } from "./file-service"; import { ApiResponse, IAdminListResponse, @@ -128,34 +129,15 @@ export const userService = { onProgress?: (percent: number) => void; }) => { 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 // 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) { console.error("ERROR caught in User Service Upload Profile Image", error); throw error; diff --git a/src/utils/shared.ts b/src/utils/shared.ts index 2fd667a..51159d2 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -139,6 +139,16 @@ export const buildQueryString = (params: Record): string => { export const serializeAxiosParams = buildQueryString; 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 = ({ index, page = 1,