Files
job-search/web/src/App.tsx
T
bwilliams e98f18b790 Added a link from each opportunity to its latest Find a Tender notice
Opportunities from Find a Tender have a link icon before their title.
Clicking it fetches the opportunity with its releases (the list leaves
them out) and opens its newest release's notice,
www.find-tender.service.gov.uk/notice/<release_id>, in a new tab. The
tab is opened on the click and pointed at the notice when the request
returns, so it isn't blocked as a popup; if the request fails, or there
are no releases, it's closed and a toast says why. Opportunities from
other sources have no link for now, but keep its space so the titles
line up.

The icon is lucide-solid's, added for it at 1.52.0.
2026-10-19 09:38:14 +01:00

110 lines
4.4 KiB
TypeScript

import type { JSX } from 'solid-js';
import { createEffect, createMemo, createSignal, Match, Switch } from 'solid-js';
import { FilterPanel } from '@/components/FilterPanel';
import { OpportunityTable } from '@/components/OpportunityTable';
import { buttonPrimary } from '@/components/styles';
import { ThemeToggle } from '@/components/ThemeToggle';
import { ToastList } from '@/components/ToastList';
import type { OpportunityFilters } from '@/lib/filterOpportunities';
import { emptyFilters, filterOpportunities, statusOptions } from '@/lib/filterOpportunities';
import { createLiveUpdates } from '@/primitives/createLiveUpdates';
import { createClassifications, createOpportunities } from '@/primitives/createOpportunities';
import { createOpportunityAnnotations } from '@/primitives/createOpportunityAnnotations';
import { createTheme } from '@/primitives/createTheme';
import { createToasts } from '@/primitives/createToasts';
const statePanel = 'rounded-[10px] border border-line bg-surface px-6 py-12 text-center';
/** The page: the filters, the table of opportunities, and toasts for errors. */
export function App(): JSX.Element {
const toasts = createToasts();
const opportunities = createOpportunities();
const classifications = createClassifications();
const annotations = createOpportunityAnnotations(opportunities.items, toasts.show);
createLiveUpdates({ opportunities, classifications, annotations });
const theme = createTheme();
const [filters, setFilters] = createSignal<OpportunityFilters>(emptyFilters());
const statuses = createMemo(() => statusOptions(opportunities.items()));
// annotations.get reads from a store, so hiding an opportunity re-runs this.
const rows = createMemo(() =>
filterOpportunities(opportunities.items(), filters(), annotations.get),
);
const clear = (): void => {
setFilters(emptyFilters());
};
// A failed load is shown where the list would be, with a retry, and as a
// toast, so it's seen wherever the user is looking.
createEffect(() => {
const err = opportunities.error();
if (err) toasts.show(`Opportunities didn't load: ${err.message}`);
});
createEffect(() => {
const err = classifications.error();
if (err) toasts.show(`Classifications didn't load: ${err.message}`);
});
return (
<div class="mx-auto max-w-[1440px] px-3 pt-4 pb-12 sm:px-6 sm:pt-7 sm:pb-16">
<header class="mb-5 flex items-center justify-between gap-4">
<h1 class="text-[1.75rem] font-bold tracking-tight">Opportunities</h1>
<ThemeToggle theme={theme.theme()} onChange={theme.setTheme} />
</header>
<FilterPanel
filters={filters()}
onChange={setFilters}
onClear={clear}
statuses={statuses()}
classifications={classifications.items()}
isLoadingClassifications={classifications.isLoading()}
classificationsError={classifications.error()}
onRetryClassifications={classifications.reload}
shown={rows().length}
total={opportunities.items().length}
/>
<main>
<Switch
fallback={
<OpportunityTable
opportunities={rows()}
annotations={annotations}
onError={toasts.show}
/>
}
>
<Match when={opportunities.isLoading() && opportunities.items().length === 0}>
<p class={`${statePanel} text-muted`}>Loading opportunities…</p>
</Match>
<Match when={opportunities.error()}>
{(err) => (
<div class={`${statePanel} text-danger`} role="alert">
<p class="mb-3">Opportunities didn't load: {err().message}</p>
<button type="button" class={buttonPrimary} onClick={opportunities.reload}>
Try again
</button>
</div>
)}
</Match>
<Match when={opportunities.items().length === 0}>
<p class={`${statePanel} text-muted`}>There are no opportunities yet.</p>
</Match>
<Match when={rows().length === 0}>
<div class={`${statePanel} text-muted`}>
<p class="mb-3">No opportunities match these filters.</p>
<button type="button" class={buttonPrimary} onClick={clear}>
Clear filters
</button>
</div>
</Match>
</Switch>
</main>
<ToastList toasts={toasts.items()} onDismiss={toasts.dismiss} />
</div>
);
}