..
This commit is contained in:
parent
1ee796b282
commit
ab688243d7
36 changed files with 307 additions and 135 deletions
|
|
@ -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);
|
||||
|
|
|
|||
63
frontend/src/components/invite/InvitePage.test.tsx
Normal file
63
frontend/src/components/invite/InvitePage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}`];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export function computeNumberLineLayout(
|
|||
* spanning the lowest→highest 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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue