This commit is contained in:
Andras Schmelczer 2026-07-03 18:39:34 +01:00
parent 1ee796b282
commit ab688243d7
36 changed files with 307 additions and 135 deletions

View file

@ -20,6 +20,7 @@ import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
import { TrashIcon } from '../ui/icons/TrashIcon';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { useLicense } from '../../hooks/useLicense';
import { useModalA11y } from '../../hooks/useModalA11y';
function PageLayout({ children }: { children: React.ReactNode }) {
return (
@ -43,17 +44,43 @@ function DeleteDialog({
onConfirm: () => void;
}) {
const { t } = useTranslation();
const dialogRef = useModalA11y();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onCancel]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onCancel}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onCancel}
role="presentation"
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="delete-search-dialog-title"
tabIndex={-1}
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 outline-none"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">{title}</h2>
<h2
id="delete-search-dialog-title"
className="text-lg font-semibold text-navy-950 dark:text-white"
>
{title}
</h2>
<button
type="button"
onClick={onCancel}
aria-label={t('common.close')}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
@ -62,12 +89,14 @@ function DeleteDialog({
<p className="px-5 pb-4 text-sm text-warm-700 dark:text-warm-300">{message}</p>
<div className="flex gap-3 justify-end px-5 pb-5">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={onConfirm}
className="px-4 py-2 text-sm rounded bg-red-600 text-white font-medium hover:bg-red-700"
>
@ -124,6 +153,7 @@ function NotesInput({ value, onSave }: { value: string; onSave: (notes: string)
value={text}
onChange={handleChange}
onBlur={handleBlur}
aria-label={t('savedPage.notesPlaceholder')}
placeholder={t('savedPage.notesPlaceholder')}
rows={1}
className="w-full resize-none overflow-hidden rounded border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-900 px-3 py-1.5 text-sm text-warm-700 dark:text-warm-300 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400"
@ -160,6 +190,7 @@ function EditableName({ value, onSave }: { value: string; onSave: (name: string)
<input
ref={inputRef}
value={text}
aria-label={t('savedPage.clickToRename')}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
@ -175,12 +206,15 @@ function EditableName({ value, onSave }: { value: string; onSave: (name: string)
}
return (
<h3
onClick={() => setEditing(true)}
className="font-medium text-navy-950 dark:text-warm-100 truncate cursor-pointer hover:text-teal-600 dark:hover:text-teal-400 border-b border-dotted border-transparent hover:border-warm-400 dark:hover:border-warm-500"
title={t('savedPage.clickToRename')}
>
{value}
<h3 className="font-medium text-navy-950 dark:text-warm-100 truncate">
<button
type="button"
onClick={() => setEditing(true)}
title={t('savedPage.clickToRename')}
className="w-full truncate text-left cursor-pointer hover:text-teal-600 dark:hover:text-teal-400 border-b border-dotted border-transparent hover:border-warm-400 dark:hover:border-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400 rounded"
>
{value}
</button>
</h3>
);
}
@ -704,7 +738,7 @@ function InviteSection({ user }: { user: AuthUser }) {
return next;
});
} catch {
// Silent non-critical
// Silent, non-critical
} finally {
setInviteHistoryLoading(false);
}
@ -853,6 +887,14 @@ export default function AccountPage({
const { t } = useTranslation();
const [newsletterSaving, setNewsletterSaving] = useState(false);
const [newsletterError, setNewsletterError] = useState<string | null>(null);
// Mirror the loaded account record's value locally so the checkbox always
// reflects the stored value and updates instantly on toggle, instead of the
// possibly-stale global auth-store value the async refresh hasn't reconciled
// yet (the source of the "checked while stored value is false" flicker).
const [newsletterChecked, setNewsletterChecked] = useState(user.newsletter);
useEffect(() => {
setNewsletterChecked(user.newsletter);
}, [user.newsletter]);
const { startCheckout, checkingOut, error: checkoutError } = useLicense();
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
@ -916,10 +958,11 @@ export default function AccountPage({
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={user.newsletter}
checked={newsletterChecked}
disabled={newsletterSaving}
onChange={async (e) => {
const checked = e.target.checked;
setNewsletterChecked(checked); // optimistic; reconciled by onRefreshAuth
setNewsletterSaving(true);
setNewsletterError(null);
try {
@ -933,6 +976,7 @@ export default function AccountPage({
assertOk(res, 'Update newsletter');
await onRefreshAuth();
} catch (err) {
setNewsletterChecked(!checked); // revert the optimistic toggle on failure
const msg =
err instanceof Error ? err.message : t('accountPage.updateNewsletterError');
setNewsletterError(msg);

View file

@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import InvitePage from './InvitePage';
vi.mock('react-i18next', async (importOriginal) => ({
...(await importOriginal<typeof import('react-i18next')>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }),
}));
function mockFetch(invite: { ok: boolean; status: number; body?: unknown }) {
return vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/invite/')) {
return Promise.resolve({
ok: invite.ok,
status: invite.status,
json: () => Promise.resolve(invite.body ?? {}),
} as Response);
}
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ current_price_pence: 4999 }),
} as Response);
});
}
const baseProps = {
user: null,
theme: 'light' as const,
onLoginClick: vi.fn(),
onRegisterClick: vi.fn(),
onLicenseGranted: vi.fn(),
};
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('InvitePage invalid-invite messaging', () => {
it('shows the unified copy for an unknown code (200 valid:false)', async () => {
vi.stubGlobal(
'fetch',
mockFetch({
ok: true,
status: 200,
body: { valid: false, invite_type: '', used: false, invited_by: null },
})
);
render(<InvitePage code="BOGUS" {...baseProps} />);
expect(await screen.findByText('invitePage.invalidInviteLink')).toBeTruthy();
});
it('shows the SAME copy for a malformed code rejected with 400', async () => {
vi.stubGlobal('fetch', mockFetch({ ok: false, status: 400 }));
render(<InvitePage code="%20" {...baseProps} />);
expect(await screen.findByText('invitePage.invalidInviteLink')).toBeTruthy();
expect(screen.queryByText('invitePage.invalidInvite')).toBeNull();
});
});

View file

@ -115,7 +115,20 @@ export default function InvitePage({
fetch(apiUrl(`invite/${encodeURIComponent(code)}`)),
fetch(apiUrl('pricing')),
]);
if (!inviteRes.ok) throw new Error('Failed to validate invite');
if (!inviteRes.ok) {
// A 4xx means the code itself is malformed or unknown, e.g. /invite/%20
// is rejected with 400 while /invite/BOGUS returns 200 {valid:false}.
// Render the SAME 'invalid invite link' copy for both so messaging is
// consistent; only 5xx / network errors fall through to the transient
// error state below.
if (inviteRes.status >= 400 && inviteRes.status < 500) {
if (!cancelled) {
setInvite({ valid: false, invite_type: '', used: false, invited_by: null });
}
return;
}
throw new Error('Failed to validate invite');
}
const data: InviteInfo = await inviteRes.json();
if (!cancelled) setInvite(data);
if (pricingRes.ok) {
@ -230,7 +243,9 @@ export default function InvitePage({
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
<HexCanvas isDark={isDark} />
<div className="text-center relative z-10">
<p className="text-lg font-medium text-white mb-2">{t('invitePage.invalidInvite')}</p>
<p className="text-lg font-medium text-white mb-2">
{t('invitePage.couldNotValidateTitle')}
</p>
<p className="text-warm-400">{error}</p>
</div>
</div>

View file

@ -13,7 +13,6 @@ import {
} from '../../lib/seoLandingPages';
import { safeJsonLd } from '../../lib/json-ld';
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
const ProductShowcase = lazy(() => import('../home/ProductShowcase'));
function ProductShowcaseFallback() {
@ -102,32 +101,11 @@ export default function SeoContentPage({
}) {
const { t, i18n } = useTranslation();
const page = getLocalizedSeoContentPage(pageKey, i18n.language);
const url = `${PUBLIC_URL}${page.path}`;
usePageMeta(page.metaTitle, page.metaDescription);
return (
<main className="flex-1 overflow-y-auto bg-warm-50 text-navy-950 dark:bg-navy-950 dark:text-warm-100">
<FaqJsonLd faq={page.faq} />
<JsonLd
data={{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Perfect Postcode',
item: `${PUBLIC_URL}/`,
},
{
'@type': 'ListItem',
position: 2,
name: page.title,
item: url,
},
],
}}
/>
<section className="bg-navy-950 text-white">
<div className="mx-auto max-w-5xl px-6 py-16 md:px-10 md:py-20">

View file

@ -10,6 +10,7 @@ import { getElectionVoteShareFeatureName } from '../../lib/election-filter';
import { getEthnicityFeatureName } from '../../lib/ethnicity-filter';
import { getQualificationFeatureName } from '../../lib/qualification-filter';
import { getTenureFeatureName } from '../../lib/tenure-filter';
import { getCouncilFeatureName } from '../../lib/council-filter';
import {
POI_DISTANCE_FILTER_NAME,
getPoiDistanceFeatureName,
@ -60,6 +61,7 @@ export default memo(function HoverCard({
const ethnicityFeatureName = getEthnicityFeatureName(name);
const qualificationFeatureName = getQualificationFeatureName(name);
const tenureFeatureName = getTenureFeatureName(name);
const councilFeatureName = getCouncilFeatureName(name);
const poiDistanceFeatureName = getPoiDistanceFeatureName(name);
const backendName =
schoolBackendName ??
@ -69,6 +71,7 @@ export default memo(function HoverCard({
ethnicityFeatureName ??
qualificationFeatureName ??
tenureFeatureName ??
councilFeatureName ??
poiDistanceFeatureName ??
name;
const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`];

View file

@ -231,7 +231,7 @@ export default function LocationSearch({
return;
}
// Postcode fetch geometry. Unlike place/address results, a postcode
// Postcode: fetch geometry. Unlike place/address results, a postcode
// result carries no coordinates, so the camera move genuinely depends on
// this response and stays gated by isCurrentLookup.
try {

View file

@ -9,11 +9,11 @@ import { MapFallback } from './map-page/Fallbacks';
*
* deck.gl runs in interleaved mode, sharing MapLibre's WebGL context. When that
* shared context gets into a bad state (lost context, a buffer/texture finalized
* while still referenced the "bufferSubData: no buffer" / "bindTexture: deleted
* while still referenced, the "bufferSubData: no buffer" / "bindTexture: deleted
* object" storm), the next interleaved draw can throw. deck.gl's own animation
* loop swallows render errors via its `onError` prop, but interleaved draws fire
* from MapLibre's synchronous `render` event, which can run inside a React commit
* so the throw bubbles to the nearest React error boundary.
* from MapLibre's synchronous `render` event, which can run inside a React commit,
* so the throw bubbles to the nearest React error boundary.
*
* Without this, that throw reaches the single top-level boundary and blanks the
* entire app ("Something went wrong / Refresh the page"). This boundary contains
@ -26,7 +26,7 @@ const MAX_AUTO_RECOVERIES = 2;
// Remount after a short delay so a transient GL state can settle first.
const RECOVERY_DELAY_MS = 400;
// Crashes spaced further apart than this are treated as independent, so the
// auto-recovery budget resets — only a tight crash loop escalates to manual retry.
// auto-recovery budget resets. Only a tight crash loop escalates to manual retry.
const STABILITY_WINDOW_MS = 30_000;
interface MapErrorBoundaryProps {

View file

@ -113,7 +113,7 @@ export default function MobileDrawer({
>
<div className="h-[10%] shrink-0" aria-hidden="true" />
{/* Panel bottom 90% */}
{/* Panel: bottom 90% */}
<div
ref={panelRef}
className="pointer-events-auto h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col border-t border-x border-warm-300 ring-1 ring-navy-950/10 shadow-2xl shadow-navy-950/45 dark:border-navy-600 dark:ring-white/10 dark:shadow-black/60 overflow-hidden"

View file

@ -40,8 +40,8 @@ describe('computeNumberLineLayout', () => {
300,
fmt
)!;
const low = layout.items.find((i) => i.kind === 'national')!; // 30 lowest
const high = layout.items.find((i) => i.kind === 'area')!; // 50 highest
const low = layout.items.find((i) => i.kind === 'national')!; // 30: lowest
const high = layout.items.find((i) => i.kind === 'area')!; // 50: highest
expect(low.tickX).toBeCloseTo(layout.plotLeft, 5);
expect(high.tickX).toBeCloseTo(layout.plotRight, 5);
});

View file

@ -120,7 +120,7 @@ export function computeNumberLineLayout(
* spanning the lowesthighest value, so the selection can be read against its
* reference points (national / outcode / sector) at a glance. Labels are spread
* to avoid overlap
* common since the nested area/sector/outcode values cluster and connected
* (common since the nested area/sector/outcode values cluster) and connected
* back to their tick with a thin leader line.
*/
export default function NumberLine({ points, format }: NumberLineProps) {

View file

@ -11,6 +11,7 @@ import { getElectionVoteShareFeatureName } from '../../../lib/election-filter';
import { getEthnicityFeatureName } from '../../../lib/ethnicity-filter';
import { getQualificationFeatureName } from '../../../lib/qualification-filter';
import { getTenureFeatureName } from '../../../lib/tenure-filter';
import { getCouncilFeatureName } from '../../../lib/council-filter';
import { getPoiDistanceFeatureName } from '../../../lib/poi-distance-filter';
import { getSchoolBackendFeatureName } from '../../../lib/school-filter';
@ -31,6 +32,7 @@ export function getMapPageBackendFeatureName(featureName: string): string {
getEthnicityFeatureName(featureName) ??
getQualificationFeatureName(featureName) ??
getTenureFeatureName(featureName) ??
getCouncilFeatureName(featureName) ??
getPoiDistanceFeatureName(featureName) ??
featureName
);

View file

@ -4,6 +4,7 @@ export const Map = lazy(() => import('../Map'));
export const Filters = lazy(() => import('../Filters'));
export const POIPane = lazy(() => import('../POIPane'));
export const OverlayPane = lazy(() => import('../OverlayPane'));
export const ListingPane = lazy(() => import('../ListingPane'));
export const AreaPane = lazy(() => import('../AreaPane'));
export const PropertiesPane = lazy(() =>
import('../PropertiesPane').then((module) => ({ default: module.PropertiesPane }))