feat(docs): migrate and reorganize frontend guides for BP panel and file uploads
- Deleted outdated documentation files for BP panel list sorting and Filestore frontend guide. - Created new documentation files for BP panel list sorting and Filestore frontend guide in the client directory. - Updated the structure and content to enhance clarity and accessibility for frontend integration with file upload and profile image APIs. - Ensured consistency in documentation style and improved examples for better developer experience.
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
normalizeToE164,
|
||||
PHONE_UI_MAX_LENGTH,
|
||||
splitE164ForEditing,
|
||||
toBestEffortE164,
|
||||
} from "@/lib/phone/e164";
|
||||
import {
|
||||
DEFAULT_PHONE_COUNTRY,
|
||||
@@ -80,8 +81,10 @@ export function PhoneNumberInput({
|
||||
|
||||
const emitE164 = useCallback(
|
||||
(nationalInput: string, countryCode: CountryCode) => {
|
||||
const e164 = normalizeToE164(nationalInput, countryCode);
|
||||
const next = e164 ?? "";
|
||||
// Preserve the user's input as best-effort E.164 instead of clearing it
|
||||
// to "" whenever it is not yet strictly valid — otherwise a typed number
|
||||
// can silently vanish from the submitted payload.
|
||||
const next = toBestEffortE164(nationalInput, countryCode);
|
||||
lastEmitted.current = next;
|
||||
onChange(next);
|
||||
},
|
||||
@@ -110,9 +113,12 @@ export function PhoneNumberInput({
|
||||
setNational(display.national);
|
||||
lastEmitted.current = e164;
|
||||
onChange(e164);
|
||||
} else if (!required) {
|
||||
lastEmitted.current = "";
|
||||
onChange("");
|
||||
} else {
|
||||
// Keep what the user typed (as best-effort E.164) so an invalid number is
|
||||
// surfaced by validation rather than silently discarded on blur.
|
||||
const next = toBestEffortE164(national, country);
|
||||
lastEmitted.current = next;
|
||||
onChange(next);
|
||||
}
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCreateAdmin, useUpdateAdmin } from "@/hooks/queries";
|
||||
import { ICreateAdminCredentials, userService } from "@/lib/api";
|
||||
import { extractFileId } from "@/lib/api/config";
|
||||
import { normalizePhoneFieldValue } from "@/components/FormElements/PhoneNumberInput";
|
||||
import { validateImageFile } from "@/utils/shared";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
@@ -34,9 +35,12 @@ const useCreateAdminPresenter = ({
|
||||
const [uploadedProfileImageUrl, setUploadedProfileImageUrl] = useState<
|
||||
string | undefined
|
||||
>(defaultValues?.profile_image);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(
|
||||
defaultValues?.profile_image ?? null,
|
||||
// The existing image rendered as a local (authenticated) object URL — used as
|
||||
// the preview baseline so "remove/replace" can fall back to it.
|
||||
const [defaultPreviewUrl, setDefaultPreviewUrl] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
@@ -62,7 +66,7 @@ const useCreateAdminPresenter = ({
|
||||
setSelectedFileSize(null);
|
||||
setUploadProgress(0);
|
||||
setUploadedProfileImageUrl(defaultValues?.profile_image);
|
||||
setPreviewUrl(defaultValues?.profile_image ?? null);
|
||||
setPreviewUrl(defaultPreviewUrl);
|
||||
};
|
||||
|
||||
const validateProfileImage = (file: File): string | null => {
|
||||
@@ -75,11 +79,15 @@ const useCreateAdminPresenter = ({
|
||||
setSelectedFileSize(file.size);
|
||||
setIsUploadingImage(true);
|
||||
setUploadProgress(0);
|
||||
// Preview the local file directly — the backend download URL is
|
||||
// auth-protected and cannot be loaded via an <img> tag.
|
||||
setPreviewUrl(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const handleUploadSuccess = (downloadUrl: string) => {
|
||||
// Store the absolute backend URL for submission, but keep the local
|
||||
// object-URL preview that was set on selection.
|
||||
setUploadedProfileImageUrl(downloadUrl);
|
||||
setPreviewUrl(downloadUrl);
|
||||
setUploadProgress(100);
|
||||
};
|
||||
|
||||
@@ -141,13 +149,57 @@ const useCreateAdminPresenter = ({
|
||||
clearErrors("profile_image");
|
||||
};
|
||||
|
||||
// Load the existing (auth-protected) profile image as a local object URL so
|
||||
// it can be previewed — a raw <img src> to the download endpoint gets 401.
|
||||
useEffect(() => {
|
||||
const source = defaultValues?.profile_image;
|
||||
if (!source) return;
|
||||
|
||||
// Non-backend URLs (if any) are directly loadable; use them as-is.
|
||||
if (!extractFileId(source)) {
|
||||
setDefaultPreviewUrl(source);
|
||||
setPreviewUrl(source);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
userService
|
||||
.fetchFileObjectUrl(source)
|
||||
.then((objectUrl) => {
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
return;
|
||||
}
|
||||
setDefaultPreviewUrl(objectUrl);
|
||||
setPreviewUrl(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave the preview empty on failure rather than showing a broken image.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [defaultValues?.profile_image]);
|
||||
|
||||
// Revoke the current preview blob when it changes, but never the baseline
|
||||
// default preview (it is reused when the user removes/replaces the image).
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl?.startsWith("blob:")) {
|
||||
if (previewUrl?.startsWith("blob:") && previewUrl !== defaultPreviewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
};
|
||||
}, [previewUrl]);
|
||||
}, [previewUrl, defaultPreviewUrl]);
|
||||
|
||||
// Revoke the baseline default preview blob on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (defaultPreviewUrl?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(defaultPreviewUrl);
|
||||
}
|
||||
};
|
||||
}, [defaultPreviewUrl]);
|
||||
|
||||
const profileImageRegister = register("profile_image", {
|
||||
onChange: onProfileImageChange,
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import { EyeIcon, GlobeIcon } from "@/assets/icons";
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import MultiSelect from "@/components/FormElements/MultiSelect";
|
||||
import {
|
||||
createPhoneFieldRules,
|
||||
PhoneNumberInput,
|
||||
} from "@/components/FormElements/PhoneNumberInput";
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import FormFooter from "@/components/ui/FormFooter";
|
||||
@@ -11,6 +15,7 @@ import { REGEX } from "@/constants/regex";
|
||||
import { FormErrorMessages } from "@/constants/Texts";
|
||||
import { CreateCustomerCredentials } from "@/lib/api";
|
||||
import { getEnumAsArray } from "@/utils/shared";
|
||||
import { Controller } from "react-hook-form";
|
||||
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
|
||||
|
||||
interface IProps {
|
||||
@@ -25,6 +30,7 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
|
||||
handleSubmit,
|
||||
register,
|
||||
watch,
|
||||
control,
|
||||
onSubmit,
|
||||
companies,
|
||||
showPassword,
|
||||
@@ -172,24 +178,20 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<InputGroup
|
||||
{...register("phone", {
|
||||
minLength: {
|
||||
value: 10,
|
||||
message: FormErrorMessages.minLength(10),
|
||||
},
|
||||
maxLength: {
|
||||
value: 20,
|
||||
message: FormErrorMessages.maxLength(20),
|
||||
},
|
||||
})}
|
||||
<Controller
|
||||
name="phone"
|
||||
label="Phone"
|
||||
type="tel"
|
||||
control={control}
|
||||
rules={createPhoneFieldRules({ required: false })}
|
||||
render={({ field }) => (
|
||||
<PhoneNumberInput
|
||||
label="Phone"
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
error={errors.phone?.message as string | undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.phone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { normalizePhoneFieldValue } from "@/components/FormElements/PhoneNumberInput";
|
||||
import {
|
||||
useCompanyFullList,
|
||||
useCreateCustomer,
|
||||
@@ -23,6 +24,7 @@ const useCreateCustomerPresenter = ({
|
||||
}: IProps) => {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
@@ -48,14 +50,19 @@ const useCreateCustomerPresenter = ({
|
||||
}
|
||||
}, [defaultValues, setValue]);
|
||||
const onSubmit: SubmitHandler<CreateCustomerCredentials> = (data) => {
|
||||
const payload: CreateCustomerCredentials = {
|
||||
...data,
|
||||
phone: normalizePhoneFieldValue(data.phone) ?? "",
|
||||
};
|
||||
|
||||
if (editMode) {
|
||||
updateCustomer({
|
||||
id: id as string,
|
||||
credentials: data,
|
||||
credentials: payload,
|
||||
});
|
||||
} else {
|
||||
createCustomer({
|
||||
...data,
|
||||
...payload,
|
||||
employee_count: data.employee_count ? +data.employee_count : undefined,
|
||||
annual_revenue: data.annual_revenue ? +data.annual_revenue : undefined,
|
||||
founded_year: data.founded_year ? +data.founded_year : undefined,
|
||||
@@ -66,6 +73,7 @@ const useCreateCustomerPresenter = ({
|
||||
handleSubmit,
|
||||
watch,
|
||||
register,
|
||||
control,
|
||||
errors,
|
||||
onSubmit,
|
||||
companies: companies?.data.companies,
|
||||
|
||||
Reference in New Issue
Block a user