feat add company links
continuous-integration/drone/push Build is passing

This commit is contained in:
AmirReza Jamali
2026-07-11 15:34:13 +03:30
parent 37aa916ad5
commit 624614f1a9
10 changed files with 278 additions and 33 deletions
@@ -5,6 +5,7 @@ import DocumentItem from "@/components/forms/companies/documents/DocumentItem";
import { ShowcaseSection } from "@/components/Layouts/showcase-section"; import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import Status from "@/components/ui/Status"; import Status from "@/components/ui/Status";
import { useCompanyDetails } from "@/hooks/queries"; import { useCompanyDetails } from "@/hooks/queries";
import type { ICompanyLink } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BreadcrumbItem } from "@/types/shared"; import { BreadcrumbItem } from "@/types/shared";
import { formatPhoneNumber, unixToDate } from "@/utils/shared"; import { formatPhoneNumber, unixToDate } from "@/utils/shared";
@@ -76,6 +77,9 @@ const CompanyDetailsPage = ({ params }: IProps) => {
const documentFileIds: string[] = Array.isArray(company.document_file_ids) const documentFileIds: string[] = Array.isArray(company.document_file_ids)
? company.document_file_ids ? company.document_file_ids
: []; : [];
const companyLinks: ICompanyLink[] = Array.isArray(company.links)
? company.links
: [];
return ( return (
<> <>
@@ -145,12 +149,18 @@ const CompanyDetailsPage = ({ params }: IProps) => {
{`${company.currency || "—"} ${getSymbolFromCurrency(company.currency ?? "USD")}`} {`${company.currency || "—"} ${getSymbolFromCurrency(company.currency ?? "USD")}`}
</span> </span>
</ShowcaseSection> </ShowcaseSection>
<ShowcaseSection title="Annual revenue" {...SHOWCASE_CENTERED_BODY}> <ShowcaseSection
title="Annual revenue"
{...SHOWCASE_CENTERED_BODY}
>
<span className="font-medium text-dark dark:text-white"> <span className="font-medium text-dark dark:text-white">
{company.annual_revenue || "—"} {company.annual_revenue || "—"}
</span> </span>
</ShowcaseSection> </ShowcaseSection>
<ShowcaseSection title="Employee count" {...SHOWCASE_CENTERED_BODY}> <ShowcaseSection
title="Employee count"
{...SHOWCASE_CENTERED_BODY}
>
<span className="font-medium text-dark dark:text-white"> <span className="font-medium text-dark dark:text-white">
{company.employee_count || "—"} {company.employee_count || "—"}
</span> </span>
@@ -230,6 +240,35 @@ const CompanyDetailsPage = ({ params }: IProps) => {
</div> </div>
</div> </div>
)} )}
{companyLinks.length > 0 && (
<div>
<SectionHeading>Links</SectionHeading>
<div className="grid gap-3 sm:grid-cols-2">
{companyLinks.map((companyLink, index) => (
<Link
key={`${companyLink.url}-${index}`}
href={companyLink.url}
target="_blank"
rel="noopener noreferrer"
className="shadow-theme-xs flex min-w-0 items-center justify-between gap-3 rounded-xl border border-stroke/70 bg-white/80 px-4 py-3 text-primary transition hover:border-primary/50 hover:bg-primary/5 dark:border-dark-3 dark:bg-dark-2/70 dark:hover:border-primary/50"
>
<span className="min-w-0">
<span className="block truncate font-medium text-dark dark:text-white">
{companyLink.title || companyLink.url}
</span>
{companyLink.title ? (
<span className="block truncate text-xs text-dark-5 dark:text-dark-6">
{companyLink.url}
</span>
) : null}
</span>
<ExternalLinkIcon />
</Link>
))}
</div>
</div>
)}
</div> </div>
</ShowcaseSection> </ShowcaseSection>
</> </>
@@ -54,7 +54,7 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
{editMode ? "Edit company" : "New company"} {editMode ? "Edit company" : "New company"}
</h1> </h1>
<p className="text-sm text-dark-5 dark:text-dark-6"> <p className="text-sm text-dark-5 dark:text-dark-6">
Profile, business details, address, tags, and supporting documents. Profile, business details, address, tags, documents, and links.
</p> </p>
</header> </header>
} }
@@ -11,6 +11,7 @@ import type { ILabelValue } from "@/types/shared";
import type { UseFormSetValue, UseFormWatch } from "react-hook-form"; import type { UseFormSetValue, UseFormWatch } from "react-hook-form";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import CompanyDocuments from "./documents/CompanyDocuments"; import CompanyDocuments from "./documents/CompanyDocuments";
import CompanyLinks from "./links/CompanyLinks";
const companyInformationSectionId = uuidv4(); const companyInformationSectionId = uuidv4();
const businessInformationSectionId = uuidv4(); const businessInformationSectionId = uuidv4();
@@ -18,6 +19,7 @@ const settingsSectionId = uuidv4();
const addressSectionId = uuidv4(); const addressSectionId = uuidv4();
const tagsSectionId = uuidv4(); const tagsSectionId = uuidv4();
const documentsSectionId = uuidv4(); const documentsSectionId = uuidv4();
const linksSectionId = uuidv4();
interface CompanyFormSectionsOptions { interface CompanyFormSectionsOptions {
editMode?: boolean; editMode?: boolean;
@@ -169,9 +171,8 @@ const getTimezoneOffsetMinutes = (timezone: string, date = new Date()) => {
timeZoneName: "shortOffset", timeZoneName: "shortOffset",
}); });
const offset = const offset =
formatter formatter.formatToParts(date).find((part) => part.type === "timeZoneName")
.formatToParts(date) ?.value ?? "";
.find((part) => part.type === "timeZoneName")?.value ?? "";
const label = offset.replace(/^GMT/, "") || "+0"; const label = offset.replace(/^GMT/, "") || "+0";
const match = label.match(/^([+-])(\d{1,2})(?::(\d{2}))?$/); 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 }) => (
<CompanyLinks control={control} errors={errors} register={register} />
),
},
],
},
]; ];
@@ -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<ICreateCompanyCredentials>;
register: UseFormRegister<ICreateCompanyCredentials>;
errors: FieldErrors<ICreateCompanyCredentials>;
}
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 (
<div className="space-y-4">
{fields.length === 0 ? (
<p className="text-sm text-dark-5 dark:text-dark-6">
No external links added yet.
</p>
) : null}
{fields.map((field, index) => {
const urlName = `links.${index}.url` as const;
const titleName = `links.${index}.title` as const;
return (
<div
key={field.id}
className="grid gap-4 rounded-xl border border-stroke/70 bg-gray-1/50 p-4 dark:border-dark-3 dark:bg-dark-2/50 sm:grid-cols-2"
>
<InputGroup<ICreateCompanyCredentials>
{...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}
/>
<InputGroup<ICreateCompanyCredentials>
{...register(urlName, {
validate: validateHttpsUrl,
setValueAs: (value: string) => value.trim(),
})}
name={urlName}
label="URL"
type="url"
placeholder="https://example.com/profile"
required
errors={errors}
/>
<div className="flex items-center justify-between sm:col-span-2">
<span className="text-xs text-error">
{get(errors, `links.${index}.root`)?.message ?? ""}
</span>
<button
type="button"
onClick={() => remove(index)}
className="rounded-lg border border-error/30 px-3 py-1.5 text-sm font-medium text-error transition hover:bg-error/10"
>
Remove
</button>
</div>
</div>
);
})}
<div className="flex items-center justify-between gap-4">
<p className="text-xs text-dark-5 dark:text-dark-6">
{fields.length}/{MAX_COMPANY_LINKS} links
</p>
<button
type="button"
disabled={fields.length >= MAX_COMPANY_LINKS}
onClick={() => append({ url: "", title: "" })}
className="rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
+ Add link
</button>
</div>
</div>
);
};
export default CompanyLinks;
@@ -30,6 +30,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
mode: "onChange", mode: "onChange",
defaultValues: { defaultValues: {
...defaultValues, ...defaultValues,
links: defaultValues?.links ?? [],
}, },
}); });
@@ -72,6 +73,10 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
employee_count: optionalNumber(data.employee_count), employee_count: optionalNumber(data.employee_count),
annual_revenue: optionalNumber(data.annual_revenue), annual_revenue: optionalNumber(data.annual_revenue),
document_file_ids: documentFileIds ?? [], document_file_ids: documentFileIds ?? [],
links: (data.links ?? []).map((link) => ({
url: link.url.trim(),
...(link.title?.trim() ? { title: link.title.trim() } : {}),
})),
tags: { tags: {
keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [], keywords: data?.tags?.keywords?.length ? data?.tags?.keywords : [],
categories: data?.tags?.categories?.length ? data?.tags?.categories : [], categories: data?.tags?.categories?.length ? data?.tags?.categories : [],
+27 -4
View File
@@ -2,6 +2,7 @@ import {
API_ENDPOINTS, API_ENDPOINTS,
companiesService, companiesService,
companyCategoriesService, companyCategoriesService,
type ICompanyLink,
type TCompanyCategoryApiResponse, type TCompanyCategoryApiResponse,
} from "@/lib/api"; } from "@/lib/api";
import { 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) => { export const useDeleteCompanyQuery = (successCallback?: () => void) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutationKey = useMemo( const mutationKey = useMemo(
@@ -224,7 +245,8 @@ export const useDeleteCompanyCategoryQuery = (successCallback?: () => void) => {
}; };
export const useToggleCompanyCategoryPublished = () => { export const useToggleCompanyCategoryPublished = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const updateOptimisticCategories = useCallback((categoryId: string) => { const updateOptimisticCategories = useCallback(
(categoryId: string) => {
queryClient.setQueriesData<TCompanyCategoryApiResponse>( queryClient.setQueriesData<TCompanyCategoryApiResponse>(
{ {
queryKey: [API_ENDPOINTS.COMPANIES.CATEGORIES.READ_ALL], queryKey: [API_ENDPOINTS.COMPANIES.CATEGORIES.READ_ALL],
@@ -247,7 +269,9 @@ export const useToggleCompanyCategoryPublished = () => {
}; };
}, },
); );
}, [queryClient]); },
[queryClient],
);
const mutationKey = useMemo( const mutationKey = useMemo(
() => [ () => [
API_ENDPOINTS.COMPANIES.CATEGORIES.TOGGLE_PUBLISHED("published"), API_ENDPOINTS.COMPANIES.CATEGORIES.TOGGLE_PUBLISHED("published"),
@@ -263,8 +287,7 @@ export const useToggleCompanyCategoryPublished = () => {
data?: { message?: string }; data?: { message?: string };
message?: string; message?: string;
}; };
const toastMessage = const toastMessage = responseBody.data?.message ?? responseBody.message;
responseBody.data?.message ?? responseBody.message;
if (toastMessage) { if (toastMessage) {
toast.success(toastMessage); toast.success(toastMessage);
} }
+1
View File
@@ -33,6 +33,7 @@ export const API_ENDPOINTS = {
DELETE: (id: string) => `companies/${id}`, DELETE: (id: string) => `companies/${id}`,
DETAILS: (id: string) => `companies/${id}`, DETAILS: (id: string) => `companies/${id}`,
DOCUMENTS: (id: string) => `companies/${id}/documents`, DOCUMENTS: (id: string) => `companies/${id}/documents`,
LINKS: (id: string) => `companies/${id}/links`,
CATEGORIES: { CATEGORIES: {
READ_ALL: "company-categories", READ_ALL: "company-categories",
CREATE: "company-categories", CREATE: "company-categories",
+18 -1
View File
@@ -6,6 +6,8 @@ import {
TCompaniesResponse, TCompaniesResponse,
TCompanyCategoryApiResponse, TCompanyCategoryApiResponse,
TCreateCompanyCategoryCredentials, TCreateCompanyCategoryCredentials,
IAddCompanyLinksResponse,
ICompanyLink,
UploadDocumentsData, UploadDocumentsData,
} from "../types"; } from "../types";
@@ -87,10 +89,25 @@ export const companiesService = {
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error("ERROR caught in Companies Services Upload Documents:", error); console.error(
"ERROR caught in Companies Services Upload Documents:",
error,
);
throw error; throw error;
} }
}, },
addLinks: async ({
id,
links,
}: {
id: string;
links: ICompanyLink[];
}): Promise<ApiResponse<IAddCompanyLinksResponse>> => {
const response = await api.post(API_ENDPOINTS.COMPANIES.LINKS(id), {
links,
});
return response.data;
},
deleteCompany: async (id: string) => { deleteCompany: async (id: string) => {
try { try {
return (await api.delete(API_ENDPOINTS.COMPANIES.DELETE(id))).data; return (await api.delete(API_ENDPOINTS.COMPANIES.DELETE(id))).data;
+14
View File
@@ -28,6 +28,11 @@ export interface ITags {
cpv_codes: string[]; cpv_codes: string[];
} }
export interface ICompanyLink {
url: string;
title?: string | null;
}
export interface ICreateCompanyCredentials { export interface ICreateCompanyCredentials {
name: string; name: string;
email: string; email: string;
@@ -48,6 +53,7 @@ export interface ICreateCompanyCredentials {
contact_person: IContactPerson; contact_person: IContactPerson;
tags: ITags; tags: ITags;
document_file_ids?: string[]; document_file_ids?: string[];
links?: ICompanyLink[];
} }
export const companySchema = z.object({ export const companySchema = z.object({
@@ -92,6 +98,14 @@ export const companySchema = z.object({
cpv_codes: z.array(z.string()).optional(), cpv_codes: z.array(z.string()).optional(),
}), }),
document_file_ids: 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({ export const companiesResponseSchema = z.object({
+20
View File
@@ -38,6 +38,16 @@ export interface ITags {
cpv_codes: string[]; cpv_codes: string[];
} }
export interface ICompanyLink {
url: string;
title?: string | null;
}
export interface IAddCompanyLinksResponse {
links: ICompanyLink[];
added: ICompanyLink[];
}
export interface ICreateCompanyCredentials { export interface ICreateCompanyCredentials {
name: string; name: string;
email: string; email: string;
@@ -58,6 +68,7 @@ export interface ICreateCompanyCredentials {
contact_person?: IContactPerson; contact_person?: IContactPerson;
tags: ITags; tags: ITags;
document_file_ids?: string[]; document_file_ids?: string[];
links?: ICompanyLink[];
} }
export const companySchema = z.object({ export const companySchema = z.object({
@@ -110,6 +121,15 @@ export const companySchema = z.object({
cpv_codes: nullableStringArraySchema, cpv_codes: nullableStringArraySchema,
}), }),
document_file_ids: z.array(z.string()).or(z.null()).optional(), 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({ export const companiesResponseSchema = z.object({