From 41e67a46e2b656b4ada4aaae55eb419c79d02e36 Mon Sep 17 00:00:00 2001 From: Ben Zhou Date: Sat, 20 Dec 2025 15:13:13 +1100 Subject: [PATCH 1/7] Fix onboarding: handle undefined values in address autocomplete and user input --- package.json | 2 +- src/app/auth/callback/AuthCallbackContent.tsx | 17 ++-- .../onboarding/components/UserInputArea.tsx | 18 ++-- src/components/GoogleOAuthButton.tsx | 2 +- src/components/ui/AddressAutocomplete.tsx | 83 +++++++++++++------ src/features/public/publicApiSlice.ts | 2 +- src/lib/axiosBaseQuery.ts | 8 +- src/services/places.ts | 2 +- 8 files changed, 87 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index 1bfccb3..1cd279f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 3000", "build": "next build", "start": "next start", "lint": "next lint --fix", diff --git a/src/app/auth/callback/AuthCallbackContent.tsx b/src/app/auth/callback/AuthCallbackContent.tsx index 12afc63..6adf802 100644 --- a/src/app/auth/callback/AuthCallbackContent.tsx +++ b/src/app/auth/callback/AuthCallbackContent.tsx @@ -51,16 +51,23 @@ export default function AuthCallbackContent() { console.log('[AuthCallback] Setting user with ID:', parsedUser._id); + // Validate parsed user data + if (!parsedUser._id || !parsedUser.email) { + console.error('[AuthCallback] Invalid user data:', parsedUser); + router.replace('/login?error=oauth_invalid_data'); + return; + } + dispatch( setCredentials({ csrfToken, user: { _id: parsedUser._id, - email: parsedUser.email, - firstName: parsedUser.firstName, - lastName: parsedUser.lastName, - role: parsedUser.role, - status: parsedUser.status, + email: parsedUser.email || '', + firstName: parsedUser.firstName || '', + lastName: parsedUser.lastName || '', + role: parsedUser.role || 'user', + status: parsedUser.status || 'active', }, }), ); diff --git a/src/app/onboarding/components/UserInputArea.tsx b/src/app/onboarding/components/UserInputArea.tsx index 64d489c..34fb60c 100644 --- a/src/app/onboarding/components/UserInputArea.tsx +++ b/src/app/onboarding/components/UserInputArea.tsx @@ -23,7 +23,7 @@ interface AddressComponents { } interface UserInputAreaProps { - userInput: string; + userInput: string | undefined; setUserInput: (value: string) => void; onTextSubmit: (input: string) => void; disabled?: boolean; @@ -73,7 +73,7 @@ export default function UserInputArea({ onAddressSelect, }: UserInputAreaProps) { const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && userInput.trim()) { + if (e.key === 'Enter' && userInput?.trim()) { e.preventDefault(); onTextSubmit(userInput); } @@ -91,7 +91,7 @@ export default function UserInputArea({ }; const handleAddressKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && userInput.trim()) { + if (e.key === 'Enter' && userInput?.trim()) { e.preventDefault(); onTextSubmit(userInput); } @@ -103,7 +103,7 @@ export default function UserInputArea({ onTextSubmit(userInput)} - disabled={disabled || userInput.trim() === ''} + onClick={() => onTextSubmit(userInput || '')} + disabled={disabled || !userInput || userInput.trim() === ''} sx={{ mb: 0.5 }} > @@ -128,7 +128,7 @@ export default function UserInputArea({ fullWidth variant="outlined" placeholder="Enter your message..." - value={userInput} + value={userInput || ''} onChange={e => setUserInput(e.target.value)} onKeyDown={handleKeyDown} disabled={disabled} @@ -136,8 +136,8 @@ export default function UserInputArea({ endAdornment: ( onTextSubmit(userInput)} - disabled={disabled || userInput.trim() === ''} + onClick={() => onTextSubmit(userInput || '')} + disabled={disabled || !userInput || userInput.trim() === ''} > diff --git a/src/components/GoogleOAuthButton.tsx b/src/components/GoogleOAuthButton.tsx index 85d257c..1713d33 100644 --- a/src/components/GoogleOAuthButton.tsx +++ b/src/components/GoogleOAuthButton.tsx @@ -91,7 +91,7 @@ export default function GoogleOAuthButton({ }: GoogleOAuthButtonProps) { const handleGoogleLogin = useCallback(() => { const backendUrl = - process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:4000/api'; + process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'; window.location.href = `${backendUrl}/auth/google`; }, []); diff --git a/src/components/ui/AddressAutocomplete.tsx b/src/components/ui/AddressAutocomplete.tsx index 179ff76..0737b3c 100644 --- a/src/components/ui/AddressAutocomplete.tsx +++ b/src/components/ui/AddressAutocomplete.tsx @@ -28,7 +28,7 @@ interface AddressComponents { } interface AddressAutocompleteProps { - value: string; + value: string | undefined; onChange: (value: string) => void; onAddressSelect: ( address: string, @@ -87,19 +87,20 @@ const AddressAutocomplete: React.FC = ({ }) => { const [suggestions, setSuggestions] = useState([]); const [loading, setLoading] = useState(false); - const [inputValue, setInputValue] = useState(value); + const [inputValue, setInputValue] = useState(value || ''); // Debounced fetch for autocomplete const debouncedFetch = useMemo( () => - debounce(async (input: string) => { - if (!input || input.trim().length < 2) { + debounce(async (input: string | undefined) => { + const inputStr = input || ''; + if (!inputStr || inputStr.trim().length < 2) { setSuggestions([]); return; } setLoading(true); try { - const data = await fetchPlaceAutocomplete(input, { + const data = await fetchPlaceAutocomplete(inputStr, { country: 'au', types: 'address', }); @@ -113,8 +114,15 @@ const AddressAutocomplete: React.FC = ({ [], ); + // Sync inputValue with value prop when it changes externally useEffect(() => { - void debouncedFetch(inputValue); + if (value !== undefined && value !== inputValue) { + setInputValue(value || ''); + } + }, [value]); + + useEffect(() => { + void debouncedFetch(inputValue || ''); return () => { debouncedFetch.cancel(); }; @@ -148,22 +156,42 @@ const AddressAutocomplete: React.FC = ({ return parsed; }; - // Format address for display + // Format address for display - ensure it matches backend regex pattern + // Backend expects: "Street, Suburb, State Postcode" (e.g., "123 Main St, Sydney, NSW 2000") const formatStructuredAddress = (components: AddressComponents): string => { const parts = []; + + // Build street address if (components.streetNumber && components.route) { parts.push(`${components.streetNumber} ${components.route}`); } else if (components.route) { parts.push(components.route); } + + // Add suburb if (components.locality) { parts.push(components.locality); } + + // Combine state and postcode (backend expects "State Postcode" format) const statePostcode = []; - if (components.administrativeAreaLevel1) - statePostcode.push(components.administrativeAreaLevel1); - if (components.postalCode) statePostcode.push(components.postalCode); - if (statePostcode.length > 0) parts.push(statePostcode.join(' ')); + if (components.administrativeAreaLevel1) { + // Ensure state is uppercase and 2-3 characters + const state = components.administrativeAreaLevel1.toUpperCase().substring(0, 3); + statePostcode.push(state); + } + if (components.postalCode) { + // Ensure postcode is 4 digits + const postcode = components.postalCode.replace(/\D/g, '').substring(0, 4); + if (postcode.length === 4) { + statePostcode.push(postcode); + } + } + + if (statePostcode.length > 0) { + parts.push(statePostcode.join(' ')); + } + return parts.join(', '); }; @@ -207,27 +235,30 @@ const AddressAutocomplete: React.FC = ({ } }; - const formatAddressForDisplay = (suggestion: AutocompletePrediction) => ( - - - {suggestion.structured_formatting?.main_text ?? suggestion.description} - - - {suggestion.structured_formatting?.secondary_text ?? ''} - - - ); + const formatAddressForDisplay = (suggestion: AutocompletePrediction | null | undefined) => { + if (!suggestion) return null; + return ( + + + {suggestion.structured_formatting?.main_text ?? suggestion.description ?? ''} + + + {suggestion.structured_formatting?.secondary_text ?? ''} + + + ); + }; return ( typeof option === 'string' ? option - : (option as AutocompletePrediction).description + : (option as AutocompletePrediction)?.description || '' } - inputValue={inputValue} + inputValue={inputValue || ''} onInputChange={handleInputChange} onChange={(event, value) => { void handleOptionSelect( @@ -259,9 +290,11 @@ const AddressAutocomplete: React.FC = ({ renderOption={(props, option) => { const { key, ...otherProps } = props as React.HTMLAttributes & { key: React.Key }; + const display = formatAddressForDisplay(option as AutocompletePrediction | null | undefined); + if (!display) return null; return (
  • - {formatAddressForDisplay(option as AutocompletePrediction)} + {display}
  • ); }} diff --git a/src/features/public/publicApiSlice.ts b/src/features/public/publicApiSlice.ts index 010c315..e600b7a 100644 --- a/src/features/public/publicApiSlice.ts +++ b/src/features/public/publicApiSlice.ts @@ -10,7 +10,7 @@ interface HealthResponse { export const publicApiSlice = createApi({ reducerPath: 'publicApi', baseQuery: fetchBaseQuery({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL, + baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api', responseHandler: 'content-type', }), endpoints: builder => ({ diff --git a/src/lib/axiosBaseQuery.ts b/src/lib/axiosBaseQuery.ts index 58311bb..a72a4c2 100644 --- a/src/lib/axiosBaseQuery.ts +++ b/src/lib/axiosBaseQuery.ts @@ -39,7 +39,7 @@ export const axiosBaseQuery = (): BaseQueryFn< ) { try { const csrfResponse = await axios({ - baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api', url: '/auth/csrf-token', method: 'GET', withCredentials: true, @@ -56,7 +56,7 @@ export const axiosBaseQuery = (): BaseQueryFn< } const result = await axios({ - baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api', url, method, data, @@ -90,7 +90,7 @@ export const axiosBaseQuery = (): BaseQueryFn< const { csrfToken: currentToken } = (getState() as RootState).auth; const refreshResponse = await axios({ - baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api', url: '/auth/refresh-csrf', method: 'POST', headers: { @@ -110,7 +110,7 @@ export const axiosBaseQuery = (): BaseQueryFn< // Retry the original request with new CSRF token const retryResult = await axios({ - baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api', url, method, data, diff --git a/src/services/places.ts b/src/services/places.ts index 1b4890f..0825449 100644 --- a/src/services/places.ts +++ b/src/services/places.ts @@ -1,4 +1,4 @@ -const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? ''; +const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'; // Autocomplete prediction structure export interface AutocompletePrediction { From 755e8b57a36be8cd7f55d7d6da238956c3675f4c Mon Sep 17 00:00:00 2001 From: Ben Zhou Date: Sat, 20 Dec 2025 20:24:48 +1100 Subject: [PATCH 2/7] fix: replace || with ?? operator in places.ts for ESLint compliance --- src/services/places.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/services/places.ts b/src/services/places.ts index 0825449..fada0f7 100644 --- a/src/services/places.ts +++ b/src/services/places.ts @@ -1,4 +1,7 @@ -const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'; +const API_BASE = + process.env.NEXT_PUBLIC_API_BASE_URL ?? + process.env.NEXT_PUBLIC_API_URL ?? + 'http://localhost:4000/api'; // Autocomplete prediction structure export interface AutocompletePrediction { From f53122e43f26d1e2d001c8673d4214700e5f7127 Mon Sep 17 00:00:00 2001 From: Ben Zhou Date: Sat, 20 Dec 2025 20:28:07 +1100 Subject: [PATCH 3/7] fix: replace all || operators with ?? for ESLint compliance - Replace || with ?? in AuthCallbackContent.tsx - Replace || with ?? in UserInputArea.tsx - Replace || with ?? in GoogleOAuthButton.tsx - Replace || with ?? in AddressAutocomplete.tsx - Replace || with ?? in publicApiSlice.ts - Replace || with ?? in axiosBaseQuery.ts - Fix useEffect dependency in AddressAutocomplete.tsx - Remove console.log statements in AuthCallbackContent.tsx --- src/app/auth/callback/AuthCallbackContent.tsx | 13 ++++++------- src/app/onboarding/components/UserInputArea.tsx | 8 ++++---- src/components/GoogleOAuthButton.tsx | 2 +- src/components/ui/AddressAutocomplete.tsx | 10 +++++----- src/features/public/publicApiSlice.ts | 2 +- src/lib/axiosBaseQuery.ts | 12 +++++++----- 6 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/app/auth/callback/AuthCallbackContent.tsx b/src/app/auth/callback/AuthCallbackContent.tsx index 6adf802..487cc39 100644 --- a/src/app/auth/callback/AuthCallbackContent.tsx +++ b/src/app/auth/callback/AuthCallbackContent.tsx @@ -49,10 +49,9 @@ export default function AuthCallbackContent() { // Clear any persisted auth state to prevent old user ID from being used localStorage.removeItem('persist:root'); - console.log('[AuthCallback] Setting user with ID:', parsedUser._id); - // Validate parsed user data if (!parsedUser._id || !parsedUser.email) { + // eslint-disable-next-line no-console console.error('[AuthCallback] Invalid user data:', parsedUser); router.replace('/login?error=oauth_invalid_data'); return; @@ -63,11 +62,11 @@ export default function AuthCallbackContent() { csrfToken, user: { _id: parsedUser._id, - email: parsedUser.email || '', - firstName: parsedUser.firstName || '', - lastName: parsedUser.lastName || '', - role: parsedUser.role || 'user', - status: parsedUser.status || 'active', + email: parsedUser.email ?? '', + firstName: parsedUser.firstName ?? '', + lastName: parsedUser.lastName ?? '', + role: parsedUser.role ?? 'user', + status: parsedUser.status ?? 'active', }, }), ); diff --git a/src/app/onboarding/components/UserInputArea.tsx b/src/app/onboarding/components/UserInputArea.tsx index 34fb60c..13aa398 100644 --- a/src/app/onboarding/components/UserInputArea.tsx +++ b/src/app/onboarding/components/UserInputArea.tsx @@ -103,7 +103,7 @@ export default function UserInputArea({ onTextSubmit(userInput || '')} + onClick={() => onTextSubmit(userInput ?? '')} disabled={disabled || !userInput || userInput.trim() === ''} sx={{ mb: 0.5 }} > @@ -128,7 +128,7 @@ export default function UserInputArea({ fullWidth variant="outlined" placeholder="Enter your message..." - value={userInput || ''} + value={userInput ?? ''} onChange={e => setUserInput(e.target.value)} onKeyDown={handleKeyDown} disabled={disabled} @@ -136,7 +136,7 @@ export default function UserInputArea({ endAdornment: ( onTextSubmit(userInput || '')} + onClick={() => onTextSubmit(userInput ?? '')} disabled={disabled || !userInput || userInput.trim() === ''} > diff --git a/src/components/GoogleOAuthButton.tsx b/src/components/GoogleOAuthButton.tsx index 1713d33..e15f9c3 100644 --- a/src/components/GoogleOAuthButton.tsx +++ b/src/components/GoogleOAuthButton.tsx @@ -91,7 +91,7 @@ export default function GoogleOAuthButton({ }: GoogleOAuthButtonProps) { const handleGoogleLogin = useCallback(() => { const backendUrl = - process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'; + process.env.NEXT_PUBLIC_API_BASE_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api'; window.location.href = `${backendUrl}/auth/google`; }, []); diff --git a/src/components/ui/AddressAutocomplete.tsx b/src/components/ui/AddressAutocomplete.tsx index 0737b3c..fd11dfb 100644 --- a/src/components/ui/AddressAutocomplete.tsx +++ b/src/components/ui/AddressAutocomplete.tsx @@ -87,7 +87,7 @@ const AddressAutocomplete: React.FC = ({ }) => { const [suggestions, setSuggestions] = useState([]); const [loading, setLoading] = useState(false); - const [inputValue, setInputValue] = useState(value || ''); + const [inputValue, setInputValue] = useState(value ?? ''); // Debounced fetch for autocomplete const debouncedFetch = useMemo( @@ -117,12 +117,12 @@ const AddressAutocomplete: React.FC = ({ // Sync inputValue with value prop when it changes externally useEffect(() => { if (value !== undefined && value !== inputValue) { - setInputValue(value || ''); + setInputValue(value ?? ''); } - }, [value]); + }, [value, inputValue]); useEffect(() => { - void debouncedFetch(inputValue || ''); + void debouncedFetch(inputValue ?? ''); return () => { debouncedFetch.cancel(); }; @@ -258,7 +258,7 @@ const AddressAutocomplete: React.FC = ({ ? option : (option as AutocompletePrediction)?.description || '' } - inputValue={inputValue || ''} + inputValue={inputValue ?? ''} onInputChange={handleInputChange} onChange={(event, value) => { void handleOptionSelect( diff --git a/src/features/public/publicApiSlice.ts b/src/features/public/publicApiSlice.ts index e600b7a..5fb35cc 100644 --- a/src/features/public/publicApiSlice.ts +++ b/src/features/public/publicApiSlice.ts @@ -10,7 +10,7 @@ interface HealthResponse { export const publicApiSlice = createApi({ reducerPath: 'publicApi', baseQuery: fetchBaseQuery({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api', + baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api', responseHandler: 'content-type', }), endpoints: builder => ({ diff --git a/src/lib/axiosBaseQuery.ts b/src/lib/axiosBaseQuery.ts index a72a4c2..e309da3 100644 --- a/src/lib/axiosBaseQuery.ts +++ b/src/lib/axiosBaseQuery.ts @@ -25,6 +25,8 @@ export const axiosBaseQuery = (): BaseQueryFn< { url, method = 'GET', data, params, headers }, { dispatch, getState }, ) => { + const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api'; + try { let { csrfToken } = (getState() as RootState).auth; const { isAuthenticated } = (getState() as RootState).auth; @@ -34,12 +36,12 @@ export const axiosBaseQuery = (): BaseQueryFn< isAuthenticated && !csrfToken && ['POST', 'PUT', 'DELETE', 'PATCH'].includes( - method?.toUpperCase() || 'GET', + method?.toUpperCase() ?? 'GET', ) ) { try { const csrfResponse = await axios({ - baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api', + baseURL: apiBaseUrl, url: '/auth/csrf-token', method: 'GET', withCredentials: true, @@ -56,7 +58,7 @@ export const axiosBaseQuery = (): BaseQueryFn< } const result = await axios({ - baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api', + baseURL: apiBaseUrl, url, method, data, @@ -90,7 +92,7 @@ export const axiosBaseQuery = (): BaseQueryFn< const { csrfToken: currentToken } = (getState() as RootState).auth; const refreshResponse = await axios({ - baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api', + baseURL: apiBaseUrl, url: '/auth/refresh-csrf', method: 'POST', headers: { @@ -110,7 +112,7 @@ export const axiosBaseQuery = (): BaseQueryFn< // Retry the original request with new CSRF token const retryResult = await axios({ - baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api', + baseURL: apiBaseUrl, url, method, data, From d1989ab007661df2518e2e780f250eec14cd3e67 Mon Sep 17 00:00:00 2001 From: Ben Zhou Date: Sat, 20 Dec 2025 20:30:45 +1100 Subject: [PATCH 4/7] fix: replace remaining || operator with ?? in AddressAutocomplete.tsx --- src/components/ui/AddressAutocomplete.tsx | 28 +++++++++++++++-------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/components/ui/AddressAutocomplete.tsx b/src/components/ui/AddressAutocomplete.tsx index fd11dfb..b71a7fe 100644 --- a/src/components/ui/AddressAutocomplete.tsx +++ b/src/components/ui/AddressAutocomplete.tsx @@ -93,7 +93,7 @@ const AddressAutocomplete: React.FC = ({ const debouncedFetch = useMemo( () => debounce(async (input: string | undefined) => { - const inputStr = input || ''; + const inputStr = input ?? ''; if (!inputStr || inputStr.trim().length < 2) { setSuggestions([]); return; @@ -160,24 +160,26 @@ const AddressAutocomplete: React.FC = ({ // Backend expects: "Street, Suburb, State Postcode" (e.g., "123 Main St, Sydney, NSW 2000") const formatStructuredAddress = (components: AddressComponents): string => { const parts = []; - + // Build street address if (components.streetNumber && components.route) { parts.push(`${components.streetNumber} ${components.route}`); } else if (components.route) { parts.push(components.route); } - + // Add suburb if (components.locality) { parts.push(components.locality); } - + // Combine state and postcode (backend expects "State Postcode" format) const statePostcode = []; if (components.administrativeAreaLevel1) { // Ensure state is uppercase and 2-3 characters - const state = components.administrativeAreaLevel1.toUpperCase().substring(0, 3); + const state = components.administrativeAreaLevel1 + .toUpperCase() + .substring(0, 3); statePostcode.push(state); } if (components.postalCode) { @@ -187,11 +189,11 @@ const AddressAutocomplete: React.FC = ({ statePostcode.push(postcode); } } - + if (statePostcode.length > 0) { parts.push(statePostcode.join(' ')); } - + return parts.join(', '); }; @@ -235,12 +237,16 @@ const AddressAutocomplete: React.FC = ({ } }; - const formatAddressForDisplay = (suggestion: AutocompletePrediction | null | undefined) => { + const formatAddressForDisplay = ( + suggestion: AutocompletePrediction | null | undefined, + ) => { if (!suggestion) return null; return ( - {suggestion.structured_formatting?.main_text ?? suggestion.description ?? ''} + {suggestion.structured_formatting?.main_text ?? + suggestion.description ?? + ''} {suggestion.structured_formatting?.secondary_text ?? ''} @@ -290,7 +296,9 @@ const AddressAutocomplete: React.FC = ({ renderOption={(props, option) => { const { key, ...otherProps } = props as React.HTMLAttributes & { key: React.Key }; - const display = formatAddressForDisplay(option as AutocompletePrediction | null | undefined); + const display = formatAddressForDisplay( + option as AutocompletePrediction | null | undefined, + ); if (!display) return null; return (
  • From 7ba06d12b10c64ab2f96fde38aa99e180ee20d8c Mon Sep 17 00:00:00 2001 From: Ben Zhou Date: Sun, 21 Dec 2025 13:51:58 +1100 Subject: [PATCH 5/7] refactor: remove hardcoded localhost URLs and centralize API config - Create unified getApiBaseUrl() utility function - Remove hardcoded localhost fallbacks from all files - Production environment now requires API URL to be configured - Development environment allows localhost fallback with warning - Improves code quality and prevents production misconfigurations Files updated: - src/utils/api-config.ts (new) - src/services/places.ts - src/lib/axiosBaseQuery.ts - src/features/public/publicApiSlice.ts - src/components/GoogleOAuthButton.tsx - src/app/(public)/blogs/page.tsx - src/app/(public)/blogs/components/BlogList.tsx - src/app/(public)/blogs/[id]/page.tsx --- src/app/(public)/blogs/[id]/page.tsx | 4 +-- .../(public)/blogs/components/BlogList.tsx | 7 +++-- src/app/(public)/blogs/page.tsx | 4 +-- src/components/GoogleOAuthButton.tsx | 5 ++-- src/features/public/publicApiSlice.ts | 3 ++- src/lib/axiosBaseQuery.ts | 5 ++-- src/services/places.ts | 7 +++-- src/utils/api-config.ts | 27 +++++++++++++++++++ 8 files changed, 45 insertions(+), 17 deletions(-) create mode 100644 src/utils/api-config.ts diff --git a/src/app/(public)/blogs/[id]/page.tsx b/src/app/(public)/blogs/[id]/page.tsx index a82af0a..cd5b4c4 100644 --- a/src/app/(public)/blogs/[id]/page.tsx +++ b/src/app/(public)/blogs/[id]/page.tsx @@ -1,5 +1,6 @@ // src/app/blogs/[id]/page.tsx import type { Blog } from '@/types/blog'; +import { getApiBaseUrl } from '@/utils/api-config'; import IntroSection from './components/IntroSection'; @@ -10,8 +11,7 @@ export default async function DetailBlogPage({ }) { // params.id is a plain string here const { id } = await params; - const baseUrl = - process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:4000/api'; + const baseUrl = getApiBaseUrl(); const res = await fetch(`${baseUrl}/blogs/${id}`, { cache: 'no-store', }); diff --git a/src/app/(public)/blogs/components/BlogList.tsx b/src/app/(public)/blogs/components/BlogList.tsx index 7d2cb6c..8cc9b5b 100644 --- a/src/app/(public)/blogs/components/BlogList.tsx +++ b/src/app/(public)/blogs/components/BlogList.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from 'react'; import theme from '@/theme'; import type { Blog } from '@/types/blog'; +import { getApiBaseUrl } from '@/utils/api-config'; import BlogCard from './BlogCard'; @@ -51,8 +52,7 @@ export default function BlogList() { if (keyword.trim()) params.set('keyword', keyword.trim()); if (topic.trim()) params.set('topic', topic.trim()); - const baseUrl = - process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:4000/api'; + const baseUrl = getApiBaseUrl(); const url = `${baseUrl}/blogs/search?${params.toString()}`; const res = await axios.get<{ @@ -82,8 +82,7 @@ export default function BlogList() { if (keyword.trim()) nextParams.set('keyword', keyword.trim()); if (topic.trim()) nextParams.set('topic', topic.trim()); - const baseUrl = - process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:4000/api'; + const baseUrl = getApiBaseUrl(); const checkUrl = `${baseUrl}/blogs/search?${nextParams.toString()}`; const res = await axios.get<{ data: Blog[] }>(checkUrl); diff --git a/src/app/(public)/blogs/page.tsx b/src/app/(public)/blogs/page.tsx index 24d6161..cdf2d4c 100644 --- a/src/app/(public)/blogs/page.tsx +++ b/src/app/(public)/blogs/page.tsx @@ -6,6 +6,7 @@ import { Suspense, useEffect, useState } from 'react'; import theme from '@/theme'; import type { Blog } from '@/types/blog'; +import { getApiBaseUrl } from '@/utils/api-config'; import Banner from './components/Banner'; import BlogFilterBar from './components/BlogFilterBar'; @@ -21,8 +22,7 @@ export default function BlogsPage() { const fetchHighlightBlogs = async () => { try { - const baseUrl = - process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:4000/api'; + const baseUrl = getApiBaseUrl(); const res = await axios.get(`${baseUrl}/blogs/highlights`, { params: { limit: 3 }, diff --git a/src/components/GoogleOAuthButton.tsx b/src/components/GoogleOAuthButton.tsx index e15f9c3..1e74bf5 100644 --- a/src/components/GoogleOAuthButton.tsx +++ b/src/components/GoogleOAuthButton.tsx @@ -4,6 +4,8 @@ import Image from 'next/image'; import { useCallback } from 'react'; import styled from 'styled-components'; +import { getApiBaseUrl } from '@/utils/api-config'; + const GoogleButton = styled.button` display: flex; align-items: center; @@ -90,8 +92,7 @@ export default function GoogleOAuthButton({ text = 'Sign in with Google', }: GoogleOAuthButtonProps) { const handleGoogleLogin = useCallback(() => { - const backendUrl = - process.env.NEXT_PUBLIC_API_BASE_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api'; + const backendUrl = getApiBaseUrl(); window.location.href = `${backendUrl}/auth/google`; }, []); diff --git a/src/features/public/publicApiSlice.ts b/src/features/public/publicApiSlice.ts index 5fb35cc..acd81e9 100644 --- a/src/features/public/publicApiSlice.ts +++ b/src/features/public/publicApiSlice.ts @@ -2,6 +2,7 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import type { Plan } from '@/types/plan.types'; +import { getApiBaseUrl } from '@/utils/api-config'; interface HealthResponse { status: string; @@ -10,7 +11,7 @@ interface HealthResponse { export const publicApiSlice = createApi({ reducerPath: 'publicApi', baseQuery: fetchBaseQuery({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api', + baseUrl: getApiBaseUrl(), responseHandler: 'content-type', }), endpoints: builder => ({ diff --git a/src/lib/axiosBaseQuery.ts b/src/lib/axiosBaseQuery.ts index e309da3..054cb50 100644 --- a/src/lib/axiosBaseQuery.ts +++ b/src/lib/axiosBaseQuery.ts @@ -5,6 +5,7 @@ import axios from 'axios'; import { logout, updateCSRFToken } from '@/features/auth/authSlice'; import type { RootState } from '@/redux/store'; +import { getApiBaseUrl } from '@/utils/api-config'; interface ErrorResponse { message: string; @@ -25,8 +26,8 @@ export const axiosBaseQuery = (): BaseQueryFn< { url, method = 'GET', data, params, headers }, { dispatch, getState }, ) => { - const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api'; - + const apiBaseUrl = getApiBaseUrl(); + try { let { csrfToken } = (getState() as RootState).auth; const { isAuthenticated } = (getState() as RootState).auth; diff --git a/src/services/places.ts b/src/services/places.ts index fada0f7..3b685b1 100644 --- a/src/services/places.ts +++ b/src/services/places.ts @@ -1,7 +1,6 @@ -const API_BASE = - process.env.NEXT_PUBLIC_API_BASE_URL ?? - process.env.NEXT_PUBLIC_API_URL ?? - 'http://localhost:4000/api'; +import { getApiBaseUrl } from '@/utils/api-config'; + +const API_BASE = getApiBaseUrl(); // Autocomplete prediction structure export interface AutocompletePrediction { diff --git a/src/utils/api-config.ts b/src/utils/api-config.ts new file mode 100644 index 0000000..68bc6e7 --- /dev/null +++ b/src/utils/api-config.ts @@ -0,0 +1,27 @@ +/** + * Get the API base URL from environment variables + * In production, environment variables must be set + * In development, falls back to localhost for convenience + */ +export function getApiBaseUrl(): string { + const apiBaseUrl = + process.env.NEXT_PUBLIC_API_BASE_URL ?? process.env.NEXT_PUBLIC_API_URL; + + if (apiBaseUrl) { + return apiBaseUrl; + } + + // Only allow localhost fallback in development + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.warn( + '⚠️ API base URL not set. Using localhost fallback for development.', + ); + return 'http://localhost:4000/api'; + } + + // In production, throw error if API URL is not configured + throw new Error( + 'API base URL is not configured. Please set NEXT_PUBLIC_API_BASE_URL or NEXT_PUBLIC_API_URL environment variable.', + ); +} From c3628090471924dde4c9e34f1b58bf0b0cde9141 Mon Sep 17 00:00:00 2001 From: Ben Zhou Date: Sun, 21 Dec 2025 20:01:58 +1100 Subject: [PATCH 6/7] refactor: simplify API config to use only NEXT_PUBLIC_API_URL - Remove unnecessary NEXT_PUBLIC_API_BASE_URL fallback - Use only NEXT_PUBLIC_API_URL as it's the actual configured variable - Simplify code and error messages --- src/utils/api-config.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/utils/api-config.ts b/src/utils/api-config.ts index 68bc6e7..4007a2a 100644 --- a/src/utils/api-config.ts +++ b/src/utils/api-config.ts @@ -4,8 +4,7 @@ * In development, falls back to localhost for convenience */ export function getApiBaseUrl(): string { - const apiBaseUrl = - process.env.NEXT_PUBLIC_API_BASE_URL ?? process.env.NEXT_PUBLIC_API_URL; + const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL; if (apiBaseUrl) { return apiBaseUrl; @@ -22,6 +21,6 @@ export function getApiBaseUrl(): string { // In production, throw error if API URL is not configured throw new Error( - 'API base URL is not configured. Please set NEXT_PUBLIC_API_BASE_URL or NEXT_PUBLIC_API_URL environment variable.', + 'API base URL is not configured. Please set NEXT_PUBLIC_API_URL environment variable.', ); } From 8fb6596cea3c39b4d02ce0d7ed5e810e7966a1c2 Mon Sep 17 00:00:00 2001 From: Ben Zhou Date: Sun, 21 Dec 2025 20:04:20 +1100 Subject: [PATCH 7/7] fix: use NEXT_PUBLIC_API_BASE_URL as the only environment variable - Change from NEXT_PUBLIC_API_URL to NEXT_PUBLIC_API_BASE_URL - This matches the actual configured environment variable name --- src/utils/api-config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/api-config.ts b/src/utils/api-config.ts index 4007a2a..7736d5f 100644 --- a/src/utils/api-config.ts +++ b/src/utils/api-config.ts @@ -4,7 +4,7 @@ * In development, falls back to localhost for convenience */ export function getApiBaseUrl(): string { - const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL; + const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; if (apiBaseUrl) { return apiBaseUrl; @@ -21,6 +21,6 @@ export function getApiBaseUrl(): string { // In production, throw error if API URL is not configured throw new Error( - 'API base URL is not configured. Please set NEXT_PUBLIC_API_URL environment variable.', + 'API base URL is not configured. Please set NEXT_PUBLIC_API_BASE_URL environment variable.', ); }