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_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
|
||||
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 { 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<T extends FieldValues> {
|
||||
title: string;
|
||||
fields: any[];
|
||||
@@ -39,6 +54,7 @@ const SectionStep = <T extends FieldValues>({
|
||||
errors,
|
||||
append,
|
||||
remove,
|
||||
control,
|
||||
fieldName,
|
||||
defaultValues,
|
||||
}: IProps<T>) => {
|
||||
@@ -82,22 +98,30 @@ const SectionStep = <T extends FieldValues>({
|
||||
key={field.id}
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2"
|
||||
>
|
||||
<InputGroup
|
||||
{...register(`cards.${index}.icon` as Path<T>, {
|
||||
<Controller
|
||||
control={control}
|
||||
name={`cards.${index}.icon` as Path<T>}
|
||||
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 }) => (
|
||||
<ImageUploadField
|
||||
value={(field.value as string) ?? ""}
|
||||
onChange={field.onChange}
|
||||
label="Icon"
|
||||
inputId={`${slugify(title)}-card-icon-${index}`}
|
||||
title="Click to upload an icon"
|
||||
hint="PNG, JPG, WEBP, SVG, GIF up to 5MB"
|
||||
recommendation="Recommended size: 128 x 128"
|
||||
fallbackFileName="Current icon"
|
||||
accept={ICON_ACCEPT}
|
||||
allowedTypes={ICON_ALLOWED_TYPES}
|
||||
errorMessage={fieldState.error?.message as string}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<InputGroup
|
||||
{...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 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,
|
||||
};
|
||||
};
|
||||
@@ -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 "./dashboard-service";
|
||||
export * from "./file-service";
|
||||
export * from "./inquiries-service";
|
||||
export * from "./profile-service";
|
||||
export * from "./tenders-service";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -139,6 +139,16 @@ export const buildQueryString = (params: Record<string, any>): 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,
|
||||
|
||||
Reference in New Issue
Block a user