src/lib/company.ts
Rama main · réplica de lectura del repositorio original.
/**
* Working out what a company's website is.
*
* The website on the company record is the answer. A posting URL is not: job
* listings live on Greenhouse, Ashby, Workday and a dozen other boards, so a
* link to one tells you where the employer advertises, not who they are —
* and a logo taken from it is the applicant tracking system's logo, which is
* both wrong and confusingly identical across half the pipeline.
*
* When there is no website we guess the name as a domain, because that is
* right often enough to be worth it and the failure is invisible: a wrong
* guess 404s and the monogram was already on screen. The company page is
* where you correct it.
*/
function hostOf(url: string): string | null {
const trimmed = url.trim();
if (!trimmed) return null;
try {
const parsed = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
return parsed.hostname.replace(/^www\./, "").toLowerCase();
} catch {
return null;
}
}
/**
* "Vertex Payments, Inc." -> "vertexpayments". Suffixes are noise in a domain,
* and they are the same noise that makes "Stripe" and "Stripe, Inc." two rows —
* so this doubles as the key for spotting a duplicate employer.
*
* Deliberately aggressive: it also folds "Meta" and "Meta Labs" together. That
* is why it may only ever SUGGEST a merge for a person to look at, never
* perform one.
*/
export function companyKey(name: string): string {
return slugify(name);
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[’']/g, "")
.replace(/\b(inc|llc|ltd|limited|corp|corporation|co|gmbh|plc|sa|ag|bv|pty|group|labs|technologies|technology)\b/g, "")
.replace(/[^a-z0-9]+/g, "");
}
/**
* The best guess at a company's domain, or null when there isn't one worth
* making. `website` wins outright; the name is a fallback and is only tried
* when it is short enough to plausibly be a domain.
*/
export function companyDomain(input: { name: string; website?: string | null }): string | null {
const fromWebsite = hostOf(input.website ?? "");
if (fromWebsite) return fromWebsite;
const slug = slugify(input.name);
return slug.length >= 2 && slug.length <= 24 ? `${slug}.com` : null;
}
/** One or two letters, for when there is no logo to show. */
export function monogram(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "?";
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
return (words[0][0] + words[1][0]).toUpperCase();
}
/**
* A stable hue per company, so the same employer is the same colour on every
* screen and two companies in a list are rarely the same. Only used behind a
* monogram, where the colour is decoration rather than information — a stage
* chip next to it is what actually carries meaning.
*/
export function monogramHue(name: string): number {
let hash = 0;
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) % 360;
return hash;
}
/**
* twenty-icons.com is Twenty CRM's free favicon service. It serves a fixed set
* of sizes and answers 400 Invalid size for anything else — so asking for the
* 52 pixels a 26px avatar actually wants gets you no logo at all, silently,
* which is indistinguishable from the company not having one.
*
* Snapping happens here rather than at the call sites so the UI can keep
* choosing whatever size the layout needs. Round up, never down: a larger
* source scaled into a smaller box stays sharp, the reverse does not.
*/
const ICON_SIZES = [16, 32, 64, 128, 180, 192];
export function logoUrl(domain: string, size = 64): string {
const served = ICON_SIZES.find((candidate) => candidate >= size) ?? ICON_SIZES[ICON_SIZES.length - 1];
return `https://twenty-icons.com/${domain}/${served}`;
}