Skip to content

Commit 2ee3aac

Browse files
authored
Merge pull request #261 from StreetSupport/chore/lighthouse-accessibility-fixes
chore: Lighthouse accessibility, performance, and security fixes
2 parents 4009d82 + 50216fe commit 2ee3aac

File tree

6 files changed

+96
-142
lines changed

6 files changed

+96
-142
lines changed

next.config.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,7 @@ const nextConfig = {
108108
// Enable module concatenation for smaller bundles
109109
config.optimization = {
110110
...config.optimization,
111-
usedExports: true,
112-
sideEffects: false,
113-
114-
// Split chunks for better caching
111+
115112
splitChunks: {
116113
...config.optimization?.splitChunks,
117114
chunks: 'all',

src/app/[slug]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import SupporterLogos from '@/components/Location/SupporterLogos';
1414
import WatsonXChat from '@/components/ui/WatsonXChat';
1515
import { generateLocationSEOMetadata } from '@/utils/seo';
1616

17-
export const dynamic = 'force-dynamic';
17+
export const revalidate = 3600;
1818

1919
// Helper function to get location background image
2020
function getLocationBackgroundImage(slug: string): string {

src/components/MapComponent/GoogleMap.tsx

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client';
22

3-
import React, { useEffect, useRef, useState } from 'react';
4-
import { loadGoogleMaps } from '@/utils/loadGoogleMaps';
3+
import React, { useEffect, useRef, useState, useCallback } from 'react';
4+
import Script from 'next/script';
5+
import { GOOGLE_MAPS_SCRIPT_URL, waitForGoogleMaps, isGoogleMapsReady } from '@/utils/loadGoogleMaps';
56
import { updateMapBounds, shouldRecalculateBounds } from '@/utils/mapBounds';
67

78
interface Marker {
@@ -40,7 +41,16 @@ interface Props {
4041
includeUserInBounds?: boolean; // Whether to include user location in bounds calculation
4142
}
4243

43-
export default React.memo(function GoogleMap({
44+
function escapeHtml(str: string): string {
45+
return str
46+
.replace(/&/g, '&')
47+
.replace(/</g, '&lt;')
48+
.replace(/>/g, '&gt;')
49+
.replace(/"/g, '&quot;')
50+
.replace(/'/g, '&#039;');
51+
}
52+
53+
export default React.memo(function GoogleMap({
4454
center,
4555
markers,
4656
zoom,
@@ -65,29 +75,16 @@ export default React.memo(function GoogleMap({
6575
onMarkerClickRef.current = onMarkerClick;
6676
const userLocationRef = useRef(userLocation);
6777
userLocationRef.current = userLocation;
68-
const [isLoaded, setIsLoaded] = useState(false);
78+
const [isLoaded, setIsLoaded] = useState(isGoogleMapsReady);
6979
const [loadError, setLoadError] = useState<string | null>(null);
7080
const effectiveZoom = zoom ?? 12;
7181

72-
// Load Google Maps API immediately (simplified approach for reliability)
73-
useEffect(() => {
74-
let isCancelled = false;
75-
76-
loadGoogleMaps()
77-
.then(() => {
78-
if (!isCancelled) {
79-
setIsLoaded(true);
80-
}
81-
})
82-
.catch((_error) => {
83-
if (!isCancelled) {
84-
setLoadError('Failed to load map. Please check your internet connection.');
85-
}
86-
});
82+
const handleScriptLoad = useCallback(() => {
83+
waitForGoogleMaps().then(() => setIsLoaded(true));
84+
}, []);
8785

88-
return () => {
89-
isCancelled = true;
90-
};
86+
const handleScriptError = useCallback(() => {
87+
setLoadError('Failed to load map. Please check your internet connection.');
9188
}, []);
9289

9390
useEffect(() => {
@@ -213,8 +210,8 @@ export default React.memo(function GoogleMap({
213210
id="${infoId}"
214211
style="font-size:14px;max-width:220px;cursor:pointer;padding:4px;"
215212
>
216-
<strong style="color:#0b9b75;">${data.organisation ?? 'Unknown Organisation'}</strong><br/>
217-
${data.serviceName ?? 'Unnamed service'}<br/>
213+
<strong style="color:#0b9b75;">${escapeHtml(data.organisation ?? 'Unknown Organisation')}</strong><br/>
214+
${escapeHtml(data.serviceName ?? 'Unnamed service')}<br/>
218215
${data.distanceKm?.toFixed(1) ?? '?'} km away
219216
</div>`;
220217

@@ -286,12 +283,20 @@ export default React.memo(function GoogleMap({
286283

287284
if (!isLoaded) {
288285
return (
289-
<div className="w-full h-full min-h-96 rounded border bg-gray-100 flex items-center justify-center">
290-
<div className="text-center">
291-
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
292-
<p className="text-gray-600">Loading map...</p>
286+
<>
287+
<Script
288+
src={GOOGLE_MAPS_SCRIPT_URL}
289+
strategy="afterInteractive"
290+
onLoad={handleScriptLoad}
291+
onError={handleScriptError}
292+
/>
293+
<div className="w-full h-full min-h-96 rounded border bg-gray-100 flex items-center justify-center">
294+
<div className="text-center">
295+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
296+
<p className="text-gray-600">Loading map...</p>
297+
</div>
293298
</div>
294-
</div>
299+
</>
295300
);
296301
}
297302

src/components/ui/WatsonXChat.tsx

Lines changed: 23 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
'use client';
22

33
import { useEffect } from 'react';
4+
import Script from 'next/script';
45
import { useCookieConsent } from '@/contexts/CookieConsentContext';
56

67
type WatsonAssistantInstance = {
78
render: () => Promise<void>;
89
destroy: () => void;
910
};
1011

11-
// Extend Window interface for WatsonX
1212
declare global {
1313
interface Window {
1414
watsonAssistantChatOptions?: {
@@ -21,7 +21,6 @@ declare global {
2121
}
2222
}
2323

24-
// West Midlands locations that should have WatsonX chat
2524
const WATSON_X_LOCATIONS = [
2625
'birmingham',
2726
'sandwell',
@@ -32,15 +31,14 @@ const WATSON_X_LOCATIONS = [
3231
'solihull'
3332
];
3433

34+
const WATSON_SCRIPT_URL =
35+
'https://web-chat.global.assistant.watson.appdomain.cloud/versions/latest/WatsonAssistantChatEntry.js';
36+
3537
interface WatsonXChatProps {
3638
locationSlug?: string;
3739
}
3840

39-
// Module-level state — survives React Strict Mode unmount/remount cycles.
40-
// The Watson SDK script is a one-shot global resource that cannot be safely
41-
// removed and re-added, so we track it outside the component lifecycle.
4241
let watsonInstance: WatsonAssistantInstance | null = null;
43-
let scriptAdded = false;
4442
let pendingCleanup: ReturnType<typeof setTimeout> | null = null;
4543

4644
function doCleanup() {
@@ -53,33 +51,17 @@ function doCleanup() {
5351
}
5452
watsonInstance = null;
5553
}
56-
scriptAdded = false;
5754
delete window.watsonAssistantChatOptions;
58-
const existing = document.querySelector(
59-
'script[src*="WatsonAssistantChatEntry.js"]'
60-
);
61-
if (existing) {
62-
existing.remove();
63-
}
6455
}
6556

66-
/**
67-
* WatsonX Chat component for West Midlands locations
68-
* Injects the IBM WatsonX Assistant chat widget
69-
*
70-
* @param locationSlug - Optional location slug to determine if chat should be shown
71-
* If not provided, chat is always shown (for west-midlands hub page)
72-
*/
7357
export default function WatsonXChat({ locationSlug }: WatsonXChatProps) {
7458
const { hasConsent } = useCookieConsent();
7559

76-
// Determine if we should show the chat widget (requires functional consent)
7760
const isWatsonLocation = !locationSlug || WATSON_X_LOCATIONS.includes(locationSlug);
7861
const hasFunctionalConsent = hasConsent('functional');
7962
const shouldShowChat = isWatsonLocation && hasFunctionalConsent;
8063

8164
useEffect(() => {
82-
// Cancel any scheduled cleanup (e.g. from Strict Mode unmount/remount)
8365
if (pendingCleanup) {
8466
clearTimeout(pendingCleanup);
8567
pendingCleanup = null;
@@ -90,36 +72,29 @@ export default function WatsonXChat({ locationSlug }: WatsonXChatProps) {
9072
return;
9173
}
9274

93-
// Already have a live instance or the script is loading — nothing to do
94-
if (watsonInstance || scriptAdded) return;
95-
96-
scriptAdded = true;
97-
98-
// Set up Watson Assistant options
99-
window.watsonAssistantChatOptions = {
100-
integrationID: '83b099b7-08a1-4118-bba3-341fbec366d1',
101-
region: 'eu-gb',
102-
serviceInstanceID: 'a3a6beaa-5967-4039-8390-d48ace365d86',
103-
onLoad: async (instance) => {
104-
watsonInstance = instance;
105-
await instance.render();
106-
}
107-
};
108-
109-
// Load the Watson Assistant script
110-
const script = document.createElement('script');
111-
script.src =
112-
'https://web-chat.global.assistant.watson.appdomain.cloud/versions/latest/WatsonAssistantChatEntry.js';
113-
script.async = true;
114-
document.head.appendChild(script);
75+
if (!watsonInstance) {
76+
window.watsonAssistantChatOptions = {
77+
integrationID: '83b099b7-08a1-4118-bba3-341fbec366d1',
78+
region: 'eu-gb',
79+
serviceInstanceID: 'a3a6beaa-5967-4039-8390-d48ace365d86',
80+
onLoad: async (instance) => {
81+
watsonInstance = instance;
82+
await instance.render();
83+
}
84+
};
85+
}
11586

11687
return () => {
117-
// Defer cleanup so a Strict Mode remount can cancel it.
118-
// If no remount follows (real unmount), cleanup fires after the timeout.
11988
pendingCleanup = setTimeout(doCleanup, 0);
12089
};
12190
}, [shouldShowChat]);
12291

123-
// This component doesn't render anything visible
124-
return null;
92+
if (!shouldShowChat) return null;
93+
94+
return (
95+
<Script
96+
src={WATSON_SCRIPT_URL}
97+
strategy="afterInteractive"
98+
/>
99+
);
125100
}

src/utils/loadGoogleMaps.ts

Lines changed: 30 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,38 @@
1-
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY!;
2-
31
/**
4-
* Loads the Google Maps JS API by injecting a script tag.
5-
* Optimized for performance with better caching and error handling.
2+
* Waits for the Google Maps JS API to become available on the window object.
3+
* The actual script tag is rendered via next/script in GoogleMap.tsx.
64
*/
7-
let isLoadingPromise: Promise<typeof google> | null = null;
8-
let isLoaded = false;
5+
export const GOOGLE_MAPS_SCRIPT_URL = `https://maps.googleapis.com/maps/api/js?key=${process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY}&v=weekly&libraries=geometry`;
96

10-
export const loadGoogleMaps = async (): Promise<typeof google> => {
11-
// If Google Maps is already loaded, return immediately
12-
if (isLoaded && typeof window !== 'undefined' && window.google && window.google.maps && window.google.maps.Map) {
13-
return window.google;
14-
}
15-
16-
// If already loading, return the existing promise
17-
if (isLoadingPromise) {
18-
return isLoadingPromise;
7+
let readyPromise: Promise<typeof google> | null = null;
8+
9+
export function isGoogleMapsReady(): boolean {
10+
return (
11+
typeof window !== 'undefined' &&
12+
!!window.google?.maps?.Map
13+
);
14+
}
15+
16+
export function waitForGoogleMaps(): Promise<typeof google> {
17+
if (isGoogleMapsReady()) {
18+
return Promise.resolve(window.google);
1919
}
20-
21-
isLoadingPromise = new Promise((resolve, reject) => {
22-
// Check if script is already in the DOM
23-
const existingScript = document.querySelector('script[src*="maps.googleapis.com"]');
24-
if (existingScript) {
25-
// Wait for it to load with timeout
26-
const checkLoaded = () => {
27-
if (window.google && window.google.maps && window.google.maps.Map) {
28-
isLoaded = true;
29-
resolve(window.google);
30-
} else {
31-
setTimeout(checkLoaded, 50); // Reduced polling interval
32-
}
33-
};
34-
checkLoaded();
35-
return;
36-
}
3720

38-
// Create and inject script tag
39-
const script = document.createElement('script');
40-
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&v=weekly&libraries=geometry`;
41-
script.async = true;
42-
script.defer = true;
43-
44-
script.onload = () => {
45-
// Wait for google.maps to be fully available with timeout
46-
const checkAvailable = () => {
47-
if (window.google && window.google.maps && window.google.maps.Map) {
48-
isLoaded = true;
49-
resolve(window.google);
50-
} else {
51-
setTimeout(checkAvailable, 50);
52-
}
53-
};
54-
checkAvailable();
55-
};
56-
57-
script.onerror = () => {
58-
isLoadingPromise = null;
59-
isLoaded = false;
60-
reject(new Error('Failed to load Google Maps API'));
21+
if (readyPromise) return readyPromise;
22+
23+
readyPromise = new Promise((resolve) => {
24+
const check = () => {
25+
if (isGoogleMapsReady()) {
26+
resolve(window.google);
27+
} else {
28+
setTimeout(check, 50);
29+
}
6130
};
62-
63-
document.head.appendChild(script);
31+
check();
6432
});
6533

66-
return isLoadingPromise;
67-
};
34+
return readyPromise;
35+
}
36+
37+
/** @deprecated Use waitForGoogleMaps instead */
38+
export const loadGoogleMaps = waitForGoogleMaps;

tests/__tests__/components/GoogleMap.test.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { render, waitFor } from '@testing-library/react';
22
import GoogleMap from '@/components/MapComponent/GoogleMap';
33

4-
// Mock the loadGoogleMaps utility
4+
jest.mock('next/script', () => {
5+
return function MockScript() { return null; };
6+
});
7+
58
jest.mock('@/utils/loadGoogleMaps', () => ({
9+
GOOGLE_MAPS_SCRIPT_URL: 'https://maps.googleapis.com/maps/api/js?key=test&v=weekly&libraries=geometry',
10+
isGoogleMapsReady: () => true,
11+
waitForGoogleMaps: jest.fn().mockResolvedValue(undefined),
612
loadGoogleMaps: jest.fn().mockResolvedValue(undefined),
713
}));
814

0 commit comments

Comments
 (0)