diff --git a/apps/web/src/api/companiesHouse.ts b/apps/web/src/api/companiesHouse.ts index ac2466a..76b06a0 100644 --- a/apps/web/src/api/companiesHouse.ts +++ b/apps/web/src/api/companiesHouse.ts @@ -333,6 +333,8 @@ const getCompanyProfile = createServerFn() }, } : undefined, + company_name: profile.company_name, + previousNames: profile.previous_company_names?.map((p) => p.name) ?? [], sicDescriptions, }; }); diff --git a/apps/web/src/components/NameHistory.tsx b/apps/web/src/components/NameHistory.tsx new file mode 100644 index 0000000..b431b49 --- /dev/null +++ b/apps/web/src/components/NameHistory.tsx @@ -0,0 +1,67 @@ +import { type ReactNode } from 'react'; + +import { titleCase } from '../utils'; +import Tooltip from './Tooltip'; + +/** + * Renders the company's current name, plus any previous names as a vertical + * "formerly known as" timeline linked by a connecting line. Falls back to a plain + * heading when there are no previous names; `children` (subtitle, industry) render + * inside the current-name node so they stay attached to it. Previous names are + * title-cased for display; the caller passes them already deduped. + */ +export function NameHistory({ + currentName, + previousNames, + children, +}: { + currentName: string; + previousNames: string[]; + children?: ReactNode; +}) { + const head = ( + <> +

{currentName}

+ {children} + + ); + + if (previousNames.length === 0) return head; + + return ( +
+ {/* Current name: a standalone heading, not part of the history list. */} +
+ + + + +
{head}
+
+
    + {previousNames.map((name, i) => { + const isLast = i === previousNames.length - 1; + return ( +
  1. + + + + +
    + + + {titleCase(name)} + + +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/apps/web/src/routes/company.$id.$slug.tsx b/apps/web/src/routes/company.$id.$slug.tsx index 4e7515b..f12d18b 100644 --- a/apps/web/src/routes/company.$id.$slug.tsx +++ b/apps/web/src/routes/company.$id.$slug.tsx @@ -14,11 +14,25 @@ import { flagStateQueryOptions } from '../api/flags'; import { getHmrcBySlug, hmrcBySlugIdQueryOptions } from '../api/hmrc'; import { AddressMap } from '../components/AddressMap'; import GovUkLogo from '../components/GovUkLogo'; +import { NameHistory } from '../components/NameHistory'; import { StatusBadge } from '../components/StatusBadge'; import { formatAddress, formatDate, formatLocation, titleCase } from '../utils'; import { buildCanonical } from '../utils/canonical'; import { buildCompanyJsonLd, ratingPhrase } from '../utils/jsonld'; +// Grammatical "A, B and C" joiner for the former-names sentence in the summary. +const listFormatter = new Intl.ListFormat('en-GB', { type: 'conjunction' }); + +// Canonical key for company-name equality (case, punctuation, LTD/LIMITED). +function normalizeName(name: string): string { + return name + .toUpperCase() + .replace(/[.,]/g, '') + .replace(/\bLIMITED\b/g, 'LTD') + .replace(/\s+/g, ' ') + .trim(); +} + export const Route = createFileRoute('/company/$id/$slug')({ validateSearch: (search: Record) => ({ search: ((search.search as string) || '').trim(), @@ -70,6 +84,7 @@ export const Route = createFileRoute('/company/$id/$slug')({ route: string; }; profile?: { + company_name?: string; company_number?: string; date_of_creation?: string; registered_office_address?: { @@ -85,9 +100,18 @@ export const Route = createFileRoute('/company/$id/$slug')({ } | undefined; + // Lead with the Companies House current name; HMRC may hold a stale former name. const name = loaderData - ? titleCase(loaderData.sponsor.organisationName) + ? titleCase( + loaderData.profile?.company_name ?? + loaderData.sponsor.organisationName, + ) : 'Company Details'; + const registeredAs = + loaderData && + normalizeName(loaderData.sponsor.organisationName) !== normalizeName(name) + ? titleCase(loaderData.sponsor.organisationName) + : ''; const location = loaderData ? formatLocation(loaderData.sponsor.townCity, loaderData.sponsor.county) : ''; @@ -100,7 +124,10 @@ export const Route = createFileRoute('/company/$id/$slug')({ location ? `Licensed UK ${route} visa sponsor in ${location}` : `Licensed UK ${route} visa sponsor`, - ].join('. '); + registeredAs ? `Also registered as ${registeredAs}` : '', + ] + .filter(Boolean) + .join('. '); const pageTitle = `${name} - UK Visa Sponsor | SponsorSearch`; const pageDescription = `${description}.`; @@ -109,7 +136,10 @@ export const Route = createFileRoute('/company/$id/$slug')({ const jsonLd = loaderData ? buildCompanyJsonLd({ name, - legalName: loaderData.sponsor.organisationName, + legalName: + loaderData.profile?.company_name ?? + loaderData.sponsor.organisationName, + alternateName: registeredAs || undefined, route, typeRating: loaderData.sponsor.typeRating, location, @@ -187,12 +217,32 @@ function CompanyDetail() { return () => observer.disconnect(); }, []); - const displayName = titleCase(sponsor.organisationName); + const hmrcName = titleCase(sponsor.organisationName); + // Lead with the Companies House current name; HMRC may hold a stale former name. + const displayName = profile?.company_name + ? titleCase(profile.company_name) + : hmrcName; + const currentKey = normalizeName( + profile?.company_name ?? sponsor.organisationName, + ); + const alsoRegisteredAs = + normalizeName(sponsor.organisationName) !== currentKey ? hmrcName : null; const displayRoute = titleCase(sponsor.route); const displayLocation = formatLocation(sponsor.townCity, sponsor.county); const industry = profile?.sicDescriptions ?.map((s) => s.description) .join(', '); + // Former names from Companies House: drop the current name and blanks, and + // dedupe (normalised) so LTD/LIMITED and repeat entries collapse. Title-casing + // happens at the display layer (NameHistory / the summary sentence). + const seenNames = new Set([currentKey]); + const formerNames: string[] = []; + for (const raw of profile?.previousNames ?? []) { + const key = normalizeName(raw); + if (!key || seenNames.has(key)) continue; + seenNames.add(key); + formerNames.push(raw); + } const incorporated = formatDate(profile?.date_of_creation); const rating = ratingPhrase(sponsor.typeRating); const intro = `${displayName} is a licensed UK ${displayRoute} visa sponsor${displayLocation ? ` based in ${displayLocation}` : ''}, holding ${rating} sponsor status on the UK Home Office register.`; @@ -205,23 +255,35 @@ function CompanyDetail() { background = `The company operates in ${industry}.`; } const outro = `${displayName} can sponsor international workers for the UK ${displayRoute} visa under its current Home Office licence.`; - const summary = [intro, background, outro].filter(Boolean).join(' '); + const registered = alsoRegisteredAs + ? `It appears on the UK Home Office sponsor register as ${alsoRegisteredAs}.` + : ''; + const history = formerNames.length + ? `It was previously known as ${listFormatter.format(formerNames.map(titleCase))}.` + : ''; + const summary = [intro, registered, background, history, outro] + .filter(Boolean) + .join(' '); return (
-

- {displayName} -

-

- Licensed UK {displayRoute} visa sponsor - {displayLocation ? ` in ${displayLocation}` : ''} -

- {industry && ( -

{industry}

- )} + +

+ Licensed UK {displayRoute} visa sponsor + {displayLocation ? ` in ${displayLocation}` : ''} +

+ {alsoRegisteredAs && ( +

+ Also registered with HMRC as {alsoRegisteredAs} +

+ )} + {industry && ( +

{industry}

+ )} +
@@ -330,7 +392,7 @@ function CompanyDetail() { address={formatAddress( profile.registered_office_address, )} - companyName={titleCase(sponsor.organisationName)} + companyName={displayName} />
diff --git a/apps/web/src/utils.ts b/apps/web/src/utils.ts index 06c1832..38e2d9a 100644 --- a/apps/web/src/utils.ts +++ b/apps/web/src/utils.ts @@ -19,7 +19,10 @@ export function titleCase(str: string | null) { if (!str) return ''; return str .toLowerCase() - .replace(/\b\w/g, (c) => c.toUpperCase()) + .replace( + /\b(\d*)([a-z])/g, + (_, digits, letter) => digits + letter.toUpperCase(), + ) .replace(/\b\w+\b/g, (word) => UPPERCASE_WORDS.has(word.toLowerCase()) ? word.toUpperCase() : word, ); diff --git a/apps/web/src/utils/jsonld.ts b/apps/web/src/utils/jsonld.ts index b60677b..f36e3ce 100644 --- a/apps/web/src/utils/jsonld.ts +++ b/apps/web/src/utils/jsonld.ts @@ -10,6 +10,7 @@ type Address = { export type CompanyJsonLdInput = { name: string; legalName: string; + alternateName?: string; route: string; typeRating: string; location: string; @@ -59,6 +60,7 @@ function organization(input: CompanyJsonLdInput) { legalName: input.legalName, url: input.canonicalUrl, }; + if (input.alternateName) org.alternateName = input.alternateName; if (input.dateOfCreation) org.foundingDate = input.dateOfCreation; if (input.companyNumber) { org.identifier = {