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:
@@ -26,6 +26,11 @@ const nextConfig = {
|
|||||||
hostname: "pub-b7fd9c30cdbf439183b75041f5f71b92.r2.dev",
|
hostname: "pub-b7fd9c30cdbf439183b75041f5f71b92.r2.dev",
|
||||||
port: "",
|
port: "",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "admin.opplenz.com",
|
||||||
|
port: "",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
normalizeToE164,
|
normalizeToE164,
|
||||||
PHONE_UI_MAX_LENGTH,
|
PHONE_UI_MAX_LENGTH,
|
||||||
splitE164ForEditing,
|
splitE164ForEditing,
|
||||||
|
toBestEffortE164,
|
||||||
} from "@/lib/phone/e164";
|
} from "@/lib/phone/e164";
|
||||||
import {
|
import {
|
||||||
DEFAULT_PHONE_COUNTRY,
|
DEFAULT_PHONE_COUNTRY,
|
||||||
@@ -80,8 +81,10 @@ export function PhoneNumberInput({
|
|||||||
|
|
||||||
const emitE164 = useCallback(
|
const emitE164 = useCallback(
|
||||||
(nationalInput: string, countryCode: CountryCode) => {
|
(nationalInput: string, countryCode: CountryCode) => {
|
||||||
const e164 = normalizeToE164(nationalInput, countryCode);
|
// Preserve the user's input as best-effort E.164 instead of clearing it
|
||||||
const next = e164 ?? "";
|
// 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;
|
lastEmitted.current = next;
|
||||||
onChange(next);
|
onChange(next);
|
||||||
},
|
},
|
||||||
@@ -110,9 +113,12 @@ export function PhoneNumberInput({
|
|||||||
setNational(display.national);
|
setNational(display.national);
|
||||||
lastEmitted.current = e164;
|
lastEmitted.current = e164;
|
||||||
onChange(e164);
|
onChange(e164);
|
||||||
} else if (!required) {
|
} else {
|
||||||
lastEmitted.current = "";
|
// Keep what the user typed (as best-effort E.164) so an invalid number is
|
||||||
onChange("");
|
// surfaced by validation rather than silently discarded on blur.
|
||||||
|
const next = toBestEffortE164(national, country);
|
||||||
|
lastEmitted.current = next;
|
||||||
|
onChange(next);
|
||||||
}
|
}
|
||||||
onBlur?.();
|
onBlur?.();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCreateAdmin, useUpdateAdmin } from "@/hooks/queries";
|
import { useCreateAdmin, useUpdateAdmin } from "@/hooks/queries";
|
||||||
import { ICreateAdminCredentials, userService } from "@/lib/api";
|
import { ICreateAdminCredentials, userService } from "@/lib/api";
|
||||||
|
import { extractFileId } from "@/lib/api/config";
|
||||||
import { normalizePhoneFieldValue } from "@/components/FormElements/PhoneNumberInput";
|
import { normalizePhoneFieldValue } from "@/components/FormElements/PhoneNumberInput";
|
||||||
import { validateImageFile } from "@/utils/shared";
|
import { validateImageFile } from "@/utils/shared";
|
||||||
import { useIsMutating } from "@tanstack/react-query";
|
import { useIsMutating } from "@tanstack/react-query";
|
||||||
@@ -34,9 +35,12 @@ const useCreateAdminPresenter = ({
|
|||||||
const [uploadedProfileImageUrl, setUploadedProfileImageUrl] = useState<
|
const [uploadedProfileImageUrl, setUploadedProfileImageUrl] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>(defaultValues?.profile_image);
|
>(defaultValues?.profile_image);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(
|
// The existing image rendered as a local (authenticated) object URL — used as
|
||||||
defaultValues?.profile_image ?? null,
|
// 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 {
|
const {
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
@@ -62,7 +66,7 @@ const useCreateAdminPresenter = ({
|
|||||||
setSelectedFileSize(null);
|
setSelectedFileSize(null);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setUploadedProfileImageUrl(defaultValues?.profile_image);
|
setUploadedProfileImageUrl(defaultValues?.profile_image);
|
||||||
setPreviewUrl(defaultValues?.profile_image ?? null);
|
setPreviewUrl(defaultPreviewUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateProfileImage = (file: File): string | null => {
|
const validateProfileImage = (file: File): string | null => {
|
||||||
@@ -75,11 +79,15 @@ const useCreateAdminPresenter = ({
|
|||||||
setSelectedFileSize(file.size);
|
setSelectedFileSize(file.size);
|
||||||
setIsUploadingImage(true);
|
setIsUploadingImage(true);
|
||||||
setUploadProgress(0);
|
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) => {
|
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);
|
setUploadedProfileImageUrl(downloadUrl);
|
||||||
setPreviewUrl(downloadUrl);
|
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,13 +149,57 @@ const useCreateAdminPresenter = ({
|
|||||||
clearErrors("profile_image");
|
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (previewUrl?.startsWith("blob:")) {
|
if (previewUrl?.startsWith("blob:") && previewUrl !== defaultPreviewUrl) {
|
||||||
URL.revokeObjectURL(previewUrl);
|
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", {
|
const profileImageRegister = register("profile_image", {
|
||||||
onChange: onProfileImageChange,
|
onChange: onProfileImageChange,
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
import { EyeIcon, GlobeIcon } from "@/assets/icons";
|
import { EyeIcon, GlobeIcon } from "@/assets/icons";
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
import MultiSelect from "@/components/FormElements/MultiSelect";
|
import MultiSelect from "@/components/FormElements/MultiSelect";
|
||||||
|
import {
|
||||||
|
createPhoneFieldRules,
|
||||||
|
PhoneNumberInput,
|
||||||
|
} from "@/components/FormElements/PhoneNumberInput";
|
||||||
import { Select } from "@/components/FormElements/select";
|
import { Select } from "@/components/FormElements/select";
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
import FormFooter from "@/components/ui/FormFooter";
|
import FormFooter from "@/components/ui/FormFooter";
|
||||||
@@ -11,6 +15,7 @@ import { REGEX } from "@/constants/regex";
|
|||||||
import { FormErrorMessages } from "@/constants/Texts";
|
import { FormErrorMessages } from "@/constants/Texts";
|
||||||
import { CreateCustomerCredentials } from "@/lib/api";
|
import { CreateCustomerCredentials } from "@/lib/api";
|
||||||
import { getEnumAsArray } from "@/utils/shared";
|
import { getEnumAsArray } from "@/utils/shared";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
|
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
@@ -25,6 +30,7 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
watch,
|
watch,
|
||||||
|
control,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
companies,
|
companies,
|
||||||
showPassword,
|
showPassword,
|
||||||
@@ -172,24 +178,20 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<InputGroup
|
<Controller
|
||||||
{...register("phone", {
|
|
||||||
minLength: {
|
|
||||||
value: 10,
|
|
||||||
message: FormErrorMessages.minLength(10),
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 20,
|
|
||||||
message: FormErrorMessages.maxLength(20),
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
name="phone"
|
name="phone"
|
||||||
|
control={control}
|
||||||
|
rules={createPhoneFieldRules({ required: false })}
|
||||||
|
render={({ field }) => (
|
||||||
|
<PhoneNumberInput
|
||||||
label="Phone"
|
label="Phone"
|
||||||
type="tel"
|
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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { normalizePhoneFieldValue } from "@/components/FormElements/PhoneNumberInput";
|
||||||
import {
|
import {
|
||||||
useCompanyFullList,
|
useCompanyFullList,
|
||||||
useCreateCustomer,
|
useCreateCustomer,
|
||||||
@@ -23,6 +24,7 @@ const useCreateCustomerPresenter = ({
|
|||||||
}: IProps) => {
|
}: IProps) => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
setValue,
|
setValue,
|
||||||
@@ -48,14 +50,19 @@ const useCreateCustomerPresenter = ({
|
|||||||
}
|
}
|
||||||
}, [defaultValues, setValue]);
|
}, [defaultValues, setValue]);
|
||||||
const onSubmit: SubmitHandler<CreateCustomerCredentials> = (data) => {
|
const onSubmit: SubmitHandler<CreateCustomerCredentials> = (data) => {
|
||||||
|
const payload: CreateCustomerCredentials = {
|
||||||
|
...data,
|
||||||
|
phone: normalizePhoneFieldValue(data.phone) ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
updateCustomer({
|
updateCustomer({
|
||||||
id: id as string,
|
id: id as string,
|
||||||
credentials: data,
|
credentials: payload,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
createCustomer({
|
createCustomer({
|
||||||
...data,
|
...payload,
|
||||||
employee_count: data.employee_count ? +data.employee_count : undefined,
|
employee_count: data.employee_count ? +data.employee_count : undefined,
|
||||||
annual_revenue: data.annual_revenue ? +data.annual_revenue : undefined,
|
annual_revenue: data.annual_revenue ? +data.annual_revenue : undefined,
|
||||||
founded_year: data.founded_year ? +data.founded_year : undefined,
|
founded_year: data.founded_year ? +data.founded_year : undefined,
|
||||||
@@ -66,6 +73,7 @@ const useCreateCustomerPresenter = ({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
errors,
|
errors,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
companies: companies?.data.companies,
|
companies: companies?.data.companies,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { API_ENDPOINTS } from "./endpoints";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute base URL of the backend admin API.
|
||||||
|
*
|
||||||
|
* Mirrors the rewrite target in `next.config.mjs` (`/api/proxy/* ->
|
||||||
|
* https://admin.opplenz.com/admin/v1/*`). Axios calls use the relative
|
||||||
|
* `/api/proxy/` base so the browser attaches auth headers same-origin, but
|
||||||
|
* values that are persisted on the backend (e.g. a `profile_image` download
|
||||||
|
* URL) must be absolute so they resolve from anywhere — not the proxy path.
|
||||||
|
*/
|
||||||
|
export const BACKEND_API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_BACKEND_API_BASE_URL ??
|
||||||
|
"https://admin.opplenz.com/admin/v1";
|
||||||
|
|
||||||
|
/** Absolute, directly-loadable URL for a stored GridFS file. */
|
||||||
|
export const buildFileDownloadUrl = (fileId: string) =>
|
||||||
|
`${BACKEND_API_BASE_URL}/${API_ENDPOINTS.FILES.DOWNLOAD(fileId)}`;
|
||||||
|
|
||||||
|
/** Pull the GridFS file id out of a stored download URL, if it is one. */
|
||||||
|
export const extractFileId = (url: string): string | undefined =>
|
||||||
|
url.match(/\/files\/([^/]+)\/download/)?.[1];
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import api from "../axios";
|
import api from "../axios";
|
||||||
|
import { buildFileDownloadUrl, extractFileId } from "../config";
|
||||||
import { API_ENDPOINTS } from "../endpoints";
|
import { API_ENDPOINTS } from "../endpoints";
|
||||||
import {
|
import {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
@@ -152,11 +153,30 @@ export const userService = {
|
|||||||
throw new Error("No file id returned from upload");
|
throw new Error("No file id returned from upload");
|
||||||
}
|
}
|
||||||
|
|
||||||
// /api/proxy/* is rewritten to /admin/v1/* in next.config.
|
// Persisted on the backend as the profile image, so it must be an
|
||||||
return `/api/proxy/${API_ENDPOINTS.FILES.DOWNLOAD(fileId)}`;
|
// absolute, directly-loadable URL — not the frontend `/api/proxy` path.
|
||||||
|
return buildFileDownloadUrl(fileId);
|
||||||
} 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;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Fetch an auth-protected file download through the proxy (so the axios
|
||||||
|
* interceptor attaches the bearer token) and return a local object URL that
|
||||||
|
* can be used directly as an `<img>` source.
|
||||||
|
*/
|
||||||
|
fetchFileObjectUrl: async (url: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const fileId = extractFileId(url);
|
||||||
|
// Route via the relative proxy endpoint when this is a backend file URL;
|
||||||
|
// otherwise fetch the URL as-is.
|
||||||
|
const path = fileId ? API_ENDPOINTS.FILES.DOWNLOAD(fileId) : url;
|
||||||
|
const response = await api.get(path, { responseType: "blob" });
|
||||||
|
return URL.createObjectURL(response.data as Blob);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ERROR caught in User Service Fetch File Object Url", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
parsePhoneNumberFromString,
|
parsePhoneNumberFromString,
|
||||||
isValidPhoneNumber,
|
isValidPhoneNumber,
|
||||||
|
getCountryCallingCode,
|
||||||
type CountryCode,
|
type CountryCode,
|
||||||
type PhoneNumber,
|
type PhoneNumber,
|
||||||
} from "libphonenumber-js";
|
} from "libphonenumber-js";
|
||||||
@@ -50,6 +51,31 @@ export function normalizeToE164(
|
|||||||
return parsed.format("E.164");
|
return parsed.format("E.164");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort E.164 for the value the user is typing.
|
||||||
|
*
|
||||||
|
* Returns the strictly-valid E.164 when the number is complete and valid;
|
||||||
|
* otherwise returns `+<callingCode><digits>` so partial/invalid input is
|
||||||
|
* preserved in form state (and surfaced by validation) instead of being
|
||||||
|
* silently reset to an empty string. Returns `""` only when no digits exist.
|
||||||
|
*/
|
||||||
|
export function toBestEffortE164(
|
||||||
|
value: string,
|
||||||
|
defaultCountry: CountryCode,
|
||||||
|
): string {
|
||||||
|
const strict = normalizeToE164(value, defaultCountry);
|
||||||
|
if (strict) return strict;
|
||||||
|
|
||||||
|
const digits = value.replace(/\D/g, "");
|
||||||
|
if (!digits) return "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
return `+${getCountryCallingCode(defaultCountry)}${digits}`;
|
||||||
|
} catch {
|
||||||
|
return `+${digits}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isValidE164Phone(value: string): boolean {
|
export function isValidE164Phone(value: string): boolean {
|
||||||
const sanitized = sanitizePhoneInput(value);
|
const sanitized = sanitizePhoneInput(value);
|
||||||
if (!sanitized) return false;
|
if (!sanitized) return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user