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:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user