diff --git a/Filestore Frontend Guide.md b/docs/client/Filestore Frontend Guide.md similarity index 100% rename from Filestore Frontend Guide.md rename to docs/client/Filestore Frontend Guide.md diff --git a/bp-panel-list-sorting.md b/docs/client/bp-panel-list-sorting.md similarity index 100% rename from bp-panel-list-sorting.md rename to docs/client/bp-panel-list-sorting.md diff --git a/next.config.mjs b/next.config.mjs index c31272d..89fd540 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -26,6 +26,11 @@ const nextConfig = { hostname: "pub-b7fd9c30cdbf439183b75041f5f71b92.r2.dev", port: "", }, + { + protocol: "https", + hostname: "admin.opplenz.com", + port: "", + }, ], }, async rewrites() { diff --git a/src/components/FormElements/PhoneNumberInput/PhoneNumberInput.tsx b/src/components/FormElements/PhoneNumberInput/PhoneNumberInput.tsx index fc0d2e3..6863362 100644 --- a/src/components/FormElements/PhoneNumberInput/PhoneNumberInput.tsx +++ b/src/components/FormElements/PhoneNumberInput/PhoneNumberInput.tsx @@ -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?.(); }; diff --git a/src/components/forms/admins/useCreateAdminPresenter.ts b/src/components/forms/admins/useCreateAdminPresenter.ts index d4d6cc5..31247d1 100644 --- a/src/components/forms/admins/useCreateAdminPresenter.ts +++ b/src/components/forms/admins/useCreateAdminPresenter.ts @@ -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( - 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( + null, ); + const [previewUrl, setPreviewUrl] = useState(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 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 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, diff --git a/src/components/forms/customers/CreateCustomer.tsx b/src/components/forms/customers/CreateCustomer.tsx index 1b3be4c..af9e2e7 100644 --- a/src/components/forms/customers/CreateCustomer.tsx +++ b/src/components/forms/customers/CreateCustomer.tsx @@ -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) => { )}
- ( + + )} /> - {errors.phone && ( -

{errors.phone.message}

- )}
diff --git a/src/components/forms/customers/useCreateCustomerPresenter.ts b/src/components/forms/customers/useCreateCustomerPresenter.ts index eff5df8..f520c5c 100644 --- a/src/components/forms/customers/useCreateCustomerPresenter.ts +++ b/src/components/forms/customers/useCreateCustomerPresenter.ts @@ -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 = (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, diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts new file mode 100644 index 0000000..48a9e6d --- /dev/null +++ b/src/lib/api/config.ts @@ -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]; diff --git a/src/lib/api/services/user-services.ts b/src/lib/api/services/user-services.ts index cc28804..f2d20f6 100644 --- a/src/lib/api/services/user-services.ts +++ b/src/lib/api/services/user-services.ts @@ -1,4 +1,5 @@ import api from "../axios"; +import { buildFileDownloadUrl, extractFileId } from "../config"; import { API_ENDPOINTS } from "../endpoints"; import { ApiResponse, @@ -152,11 +153,30 @@ export const userService = { throw new Error("No file id returned from upload"); } - // /api/proxy/* is rewritten to /admin/v1/* in next.config. - return `/api/proxy/${API_ENDPOINTS.FILES.DOWNLOAD(fileId)}`; + // 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); } catch (error) { console.error("ERROR caught in User Service Upload Profile Image", 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 `` source. + */ + fetchFileObjectUrl: async (url: string): Promise => { + 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; + } + }, }; diff --git a/src/lib/phone/e164.ts b/src/lib/phone/e164.ts index aa9ceb4..b5b91d7 100644 --- a/src/lib/phone/e164.ts +++ b/src/lib/phone/e164.ts @@ -1,6 +1,7 @@ import { parsePhoneNumberFromString, isValidPhoneNumber, + getCountryCallingCode, type CountryCode, type PhoneNumber, } from "libphonenumber-js"; @@ -50,6 +51,31 @@ export function normalizeToE164( 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 `+` 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 { const sanitized = sanitizePhoneInput(value); if (!sanitized) return false;