diff --git a/src/app/(companies)/companies/details/[id]/page.tsx b/src/app/(companies)/companies/details/[id]/page.tsx index 28565b9..fd3953c 100644 --- a/src/app/(companies)/companies/details/[id]/page.tsx +++ b/src/app/(companies)/companies/details/[id]/page.tsx @@ -5,6 +5,7 @@ import DocumentItem from "@/components/forms/companies/documents/DocumentItem"; import { ShowcaseSection } from "@/components/Layouts/showcase-section"; import Status from "@/components/ui/Status"; import { useCompanyDetails } from "@/hooks/queries"; +import type { ICompanyLink } from "@/lib/api"; import { cn } from "@/lib/utils"; import { BreadcrumbItem } from "@/types/shared"; import { formatPhoneNumber, unixToDate } from "@/utils/shared"; @@ -76,6 +77,9 @@ const CompanyDetailsPage = ({ params }: IProps) => { const documentFileIds: string[] = Array.isArray(company.document_file_ids) ? company.document_file_ids : []; + const companyLinks: ICompanyLink[] = Array.isArray(company.links) + ? company.links + : []; return ( <> @@ -145,12 +149,18 @@ const CompanyDetailsPage = ({ params }: IProps) => { {`${company.currency || "—"} ${getSymbolFromCurrency(company.currency ?? "USD")}`} - + {company.annual_revenue || "—"} - + {company.employee_count || "—"} @@ -230,6 +240,35 @@ const CompanyDetailsPage = ({ params }: IProps) => { )} + + {companyLinks.length > 0 && ( +
+ Links +
+ {companyLinks.map((companyLink, index) => ( + + + + {companyLink.title || companyLink.url} + + {companyLink.title ? ( + + {companyLink.url} + + ) : null} + + + + ))} +
+
+ )}
diff --git a/src/components/forms/companies/CreateCompany.tsx b/src/components/forms/companies/CreateCompany.tsx index 82d272e..9f62abe 100644 --- a/src/components/forms/companies/CreateCompany.tsx +++ b/src/components/forms/companies/CreateCompany.tsx @@ -54,7 +54,7 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => { {editMode ? "Edit company" : "New company"}

- Profile, business details, address, tags, and supporting documents. + Profile, business details, address, tags, documents, and links.

} diff --git a/src/components/forms/companies/companyFormSections.tsx b/src/components/forms/companies/companyFormSections.tsx index 4b14d41..bb46e4e 100644 --- a/src/components/forms/companies/companyFormSections.tsx +++ b/src/components/forms/companies/companyFormSections.tsx @@ -11,6 +11,7 @@ import type { ILabelValue } from "@/types/shared"; import type { UseFormSetValue, UseFormWatch } from "react-hook-form"; import { v4 as uuidv4 } from "uuid"; import CompanyDocuments from "./documents/CompanyDocuments"; +import CompanyLinks from "./links/CompanyLinks"; const companyInformationSectionId = uuidv4(); const businessInformationSectionId = uuidv4(); @@ -18,6 +19,7 @@ const settingsSectionId = uuidv4(); const addressSectionId = uuidv4(); const tagsSectionId = uuidv4(); const documentsSectionId = uuidv4(); +const linksSectionId = uuidv4(); interface CompanyFormSectionsOptions { editMode?: boolean; @@ -169,9 +171,8 @@ const getTimezoneOffsetMinutes = (timezone: string, date = new Date()) => { timeZoneName: "shortOffset", }); const offset = - formatter - .formatToParts(date) - .find((part) => part.type === "timeZoneName")?.value ?? ""; + formatter.formatToParts(date).find((part) => part.type === "timeZoneName") + ?.value ?? ""; const label = offset.replace(/^GMT/, "") || "+0"; const match = label.match(/^([+-])(\d{1,2})(?::(\d{2}))?$/); @@ -667,4 +668,19 @@ export const createCompanyFormSections = ({ }, ], }, + { + id: linksSectionId, + title: "Links", + rootClassName: "lg:col-span-2", + className: "md:grid-cols-1", + fields: [ + { + kind: "custom", + name: "links", + render: ({ control, errors, register }) => ( + + ), + }, + ], + }, ]; diff --git a/src/components/forms/companies/links/CompanyLinks.tsx b/src/components/forms/companies/links/CompanyLinks.tsx new file mode 100644 index 0000000..7d6273a --- /dev/null +++ b/src/components/forms/companies/links/CompanyLinks.tsx @@ -0,0 +1,110 @@ +"use client"; + +import InputGroup from "@/components/FormElements/InputGroup"; +import type { ICreateCompanyCredentials } from "@/lib/api"; +import type { Control, FieldErrors, UseFormRegister } from "react-hook-form"; +import { get, useFieldArray } from "react-hook-form"; + +const MAX_COMPANY_LINKS = 20; + +interface CompanyLinksProps { + control: Control; + register: UseFormRegister; + errors: FieldErrors; +} + +const validateHttpsUrl = (value?: string) => { + const trimmedValue = value?.trim() ?? ""; + if (!trimmedValue) return "URL is required"; + + try { + const url = new URL(trimmedValue); + return url.protocol === "https:" || "Enter a valid HTTPS URL"; + } catch { + return "Enter a valid HTTPS URL"; + } +}; + +const CompanyLinks = ({ control, register, errors }: CompanyLinksProps) => { + const { fields, append, remove } = useFieldArray({ + control, + name: "links", + }); + + return ( +
+ {fields.length === 0 ? ( +

+ No external links added yet. +

+ ) : null} + + {fields.map((field, index) => { + const urlName = `links.${index}.url` as const; + const titleName = `links.${index}.title` as const; + + return ( +
+ + {...register(titleName, { + setValueAs: (value: string) => value.trim(), + maxLength: { + value: 200, + message: "Title must be 200 characters or fewer", + }, + })} + name={titleName} + label="Title (optional)" + type="text" + placeholder="LinkedIn" + errors={errors} + /> + + {...register(urlName, { + validate: validateHttpsUrl, + setValueAs: (value: string) => value.trim(), + })} + name={urlName} + label="URL" + type="url" + placeholder="https://example.com/profile" + required + errors={errors} + /> +
+ + {get(errors, `links.${index}.root`)?.message ?? ""} + + +
+
+ ); + })} + +
+

+ {fields.length}/{MAX_COMPANY_LINKS} links +

+ +
+
+ ); +}; + +export default CompanyLinks; diff --git a/src/components/forms/companies/useCreateCompanyPresenter.ts b/src/components/forms/companies/useCreateCompanyPresenter.ts index c6bdad7..a4a3a69 100644 --- a/src/components/forms/companies/useCreateCompanyPresenter.ts +++ b/src/components/forms/companies/useCreateCompanyPresenter.ts @@ -30,6 +30,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => { mode: "onChange", defaultValues: { ...defaultValues, + links: defaultValues?.links ?? [], }, }); @@ -72,6 +73,10 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => { employee_count: optionalNumber(data.employee_count), annual_revenue: optionalNumber(data.annual_revenue), document_file_ids: documentFileIds ?? [], + links: (data.links ?? []).map((link) => ({ + url: link.url.trim(), + ...(link.title?.trim() ? { title: link.title.trim() } : {}), + })), tags: { keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [], categories: data?.tags?.categories?.length ? data?.tags?.categories : [], diff --git a/src/hooks/queries/useCompaniesQueries.ts b/src/hooks/queries/useCompaniesQueries.ts index 48a90b5..abfb248 100644 --- a/src/hooks/queries/useCompaniesQueries.ts +++ b/src/hooks/queries/useCompaniesQueries.ts @@ -2,6 +2,7 @@ import { API_ENDPOINTS, companiesService, companyCategoriesService, + type ICompanyLink, type TCompanyCategoryApiResponse, } from "@/lib/api"; import { @@ -100,6 +101,26 @@ export const useUploadCompanyDocuments = (id: string) => { }); }; +export const useAddCompanyLinks = (id: string) => { + const queryClient = useQueryClient(); + const mutationKey = useMemo( + () => [API_ENDPOINTS.COMPANIES.LINKS(id), "add-links"], + [id], + ); + + return useMutation({ + mutationKey, + mutationFn: (links: ICompanyLink[]) => + companiesService.addLinks({ id, links }), + onSuccess: (response) => { + toast.success(response.message ?? "Company links added successfully"); + queryClient.invalidateQueries({ + queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, id, "details"], + }); + }, + }); +}; + export const useDeleteCompanyQuery = (successCallback?: () => void) => { const queryClient = useQueryClient(); const mutationKey = useMemo( @@ -224,30 +245,33 @@ export const useDeleteCompanyCategoryQuery = (successCallback?: () => void) => { }; export const useToggleCompanyCategoryPublished = () => { const queryClient = useQueryClient(); - const updateOptimisticCategories = useCallback((categoryId: string) => { - queryClient.setQueriesData( - { - queryKey: [API_ENDPOINTS.COMPANIES.CATEGORIES.READ_ALL], - exact: false, - }, - (previousList) => { - if (!previousList?.data?.categories) { - return previousList; - } - return { - ...previousList, - data: { - ...previousList.data, - categories: previousList.data.categories.map((category) => - category.id === categoryId - ? { ...category, published: !category.published } - : category, - ), - }, - }; - }, - ); - }, [queryClient]); + const updateOptimisticCategories = useCallback( + (categoryId: string) => { + queryClient.setQueriesData( + { + queryKey: [API_ENDPOINTS.COMPANIES.CATEGORIES.READ_ALL], + exact: false, + }, + (previousList) => { + if (!previousList?.data?.categories) { + return previousList; + } + return { + ...previousList, + data: { + ...previousList.data, + categories: previousList.data.categories.map((category) => + category.id === categoryId + ? { ...category, published: !category.published } + : category, + ), + }, + }; + }, + ); + }, + [queryClient], + ); const mutationKey = useMemo( () => [ API_ENDPOINTS.COMPANIES.CATEGORIES.TOGGLE_PUBLISHED("published"), @@ -263,8 +287,7 @@ export const useToggleCompanyCategoryPublished = () => { data?: { message?: string }; message?: string; }; - const toastMessage = - responseBody.data?.message ?? responseBody.message; + const toastMessage = responseBody.data?.message ?? responseBody.message; if (toastMessage) { toast.success(toastMessage); } diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts index 13e9e4c..f0d377b 100644 --- a/src/lib/api/endpoints.ts +++ b/src/lib/api/endpoints.ts @@ -33,6 +33,7 @@ export const API_ENDPOINTS = { DELETE: (id: string) => `companies/${id}`, DETAILS: (id: string) => `companies/${id}`, DOCUMENTS: (id: string) => `companies/${id}/documents`, + LINKS: (id: string) => `companies/${id}/links`, CATEGORIES: { READ_ALL: "company-categories", CREATE: "company-categories", diff --git a/src/lib/api/services/companies-service.ts b/src/lib/api/services/companies-service.ts index 84f6bbf..c7b904f 100644 --- a/src/lib/api/services/companies-service.ts +++ b/src/lib/api/services/companies-service.ts @@ -6,6 +6,8 @@ import { TCompaniesResponse, TCompanyCategoryApiResponse, TCreateCompanyCategoryCredentials, + IAddCompanyLinksResponse, + ICompanyLink, UploadDocumentsData, } from "../types"; @@ -87,10 +89,25 @@ export const companiesService = { return response.data; } catch (error) { - console.error("ERROR caught in Companies Services Upload Documents:", error); + console.error( + "ERROR caught in Companies Services Upload Documents:", + error, + ); throw error; } }, + addLinks: async ({ + id, + links, + }: { + id: string; + links: ICompanyLink[]; + }): Promise> => { + const response = await api.post(API_ENDPOINTS.COMPANIES.LINKS(id), { + links, + }); + return response.data; + }, deleteCompany: async (id: string) => { try { return (await api.delete(API_ENDPOINTS.COMPANIES.DELETE(id))).data; diff --git a/src/lib/api/types/Company.ts b/src/lib/api/types/Company.ts index 9861ee8..7d3d265 100644 --- a/src/lib/api/types/Company.ts +++ b/src/lib/api/types/Company.ts @@ -28,6 +28,11 @@ export interface ITags { cpv_codes: string[]; } +export interface ICompanyLink { + url: string; + title?: string | null; +} + export interface ICreateCompanyCredentials { name: string; email: string; @@ -48,6 +53,7 @@ export interface ICreateCompanyCredentials { contact_person: IContactPerson; tags: ITags; document_file_ids?: string[]; + links?: ICompanyLink[]; } export const companySchema = z.object({ @@ -92,6 +98,14 @@ export const companySchema = z.object({ cpv_codes: z.array(z.string()).optional(), }), document_file_ids: z.array(z.string()).optional(), + links: z + .array( + z.object({ + url: z.string().url(), + title: z.string().nullable().optional(), + }), + ) + .optional(), }); export const companiesResponseSchema = z.object({ diff --git a/src/lib/api/types/TCompany.ts b/src/lib/api/types/TCompany.ts index 720817f..4888b7d 100644 --- a/src/lib/api/types/TCompany.ts +++ b/src/lib/api/types/TCompany.ts @@ -38,6 +38,16 @@ export interface ITags { cpv_codes: string[]; } +export interface ICompanyLink { + url: string; + title?: string | null; +} + +export interface IAddCompanyLinksResponse { + links: ICompanyLink[]; + added: ICompanyLink[]; +} + export interface ICreateCompanyCredentials { name: string; email: string; @@ -58,6 +68,7 @@ export interface ICreateCompanyCredentials { contact_person?: IContactPerson; tags: ITags; document_file_ids?: string[]; + links?: ICompanyLink[]; } export const companySchema = z.object({ @@ -110,6 +121,15 @@ export const companySchema = z.object({ cpv_codes: nullableStringArraySchema, }), document_file_ids: z.array(z.string()).or(z.null()).optional(), + links: z + .array( + z.object({ + url: z.string().url(), + title: z.string().nullable().optional(), + }), + ) + .or(z.null()) + .optional(), }); export const companiesResponseSchema = z.object({