Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/src/api/companiesHouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ const getCompanyProfile = createServerFn()
},
}
: undefined,
company_name: profile.company_name,
previousNames: profile.previous_company_names?.map((p) => p.name) ?? [],
sicDescriptions,
};
});
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/components/NameHistory.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<>
<h1 className="text-xl font-semibold text-(--sea-ink)">{currentName}</h1>
{children}
</>
);

if (previousNames.length === 0) return head;

return (
<div>
{/* Current name: a standalone heading, not part of the history list. */}
<div className="flex gap-2.5">
<span className="relative w-2 shrink-0" aria-hidden>
<span className="absolute top-3.5 bottom-0 left-1/2 w-px -translate-x-1/2 bg-(--sea-ink-soft)/30" />
<span className="absolute top-2.5 left-1/2 size-2 -translate-x-1/2 rounded-full bg-(--sea-ink)" />
</span>
<div className="min-w-0 pb-2">{head}</div>
</div>
<ol className="m-0 list-none p-0" aria-label="Previous company names">
{previousNames.map((name, i) => {
const isLast = i === previousNames.length - 1;
return (
<li key={`${name}-${i}`} className="flex gap-2.5">
<span className="relative w-2 shrink-0" aria-hidden>
<span
className={`absolute left-1/2 w-px -translate-x-1/2 bg-(--sea-ink-soft)/30 ${
isLast ? 'top-0 h-2.5' : 'inset-y-0'
}`}
/>
<span className="absolute top-1.5 left-1/2 size-2 -translate-x-1/2 rounded-full border border-(--sea-ink-soft) bg-(--sponsor-card-bg)" />
</span>
<div className={`min-w-0 ${isLast ? '' : 'pb-2'}`}>
<Tooltip text="Previous company name">
<span className="text-sm text-(--sea-ink-soft)">
{titleCase(name)}
</span>
</Tooltip>
</div>
</li>
);
})}
</ol>
</div>
);
}
94 changes: 78 additions & 16 deletions apps/web/src/routes/company.$id.$slug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => ({
search: ((search.search as string) || '').trim(),
Expand Down Expand Up @@ -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?: {
Expand All @@ -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)
: '';
Expand All @@ -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}.`;
Expand All @@ -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,
Expand Down Expand Up @@ -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.`;
Expand All @@ -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 (
<main className="page-wrap min-h-[50vh] px-4 py-16">
<section className="mx-auto max-w-2xl">
<div className="page-flip-details">
<div className="rounded-lg bg-(--sponsor-card-bg) p-6 shadow-(--shadow-card)">
<h1 className="text-xl font-semibold text-(--sea-ink)">
{displayName}
</h1>
<p className="mt-1 text-sm text-(--sea-ink)">
Licensed UK {displayRoute} visa sponsor
{displayLocation ? ` in ${displayLocation}` : ''}
</p>
{industry && (
<p className="mt-1 text-sm text-(--sea-ink-soft)">{industry}</p>
)}
<NameHistory currentName={displayName} previousNames={formerNames}>
<p className="mt-1 text-sm text-(--sea-ink)">
Licensed UK {displayRoute} visa sponsor
{displayLocation ? ` in ${displayLocation}` : ''}
</p>
{alsoRegisteredAs && (
<p className="mt-1 text-sm text-(--sea-ink-soft)">
Also registered with HMRC as {alsoRegisteredAs}
</p>
)}
{industry && (
<p className="mt-1 text-sm text-(--sea-ink-soft)">{industry}</p>
)}
</NameHistory>
<dl className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<dt className="text-[10px] font-medium tracking-wider text-(--sea-ink-soft) uppercase">
Expand Down Expand Up @@ -330,7 +392,7 @@ function CompanyDetail() {
address={formatAddress(
profile.registered_office_address,
)}
companyName={titleCase(sponsor.organisationName)}
companyName={displayName}
/>
</div>
</dd>
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/utils/jsonld.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type Address = {
export type CompanyJsonLdInput = {
name: string;
legalName: string;
alternateName?: string;
route: string;
typeRating: string;
location: string;
Expand Down Expand Up @@ -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 = {
Expand Down
Loading