diff --git a/package.json b/package.json index 50bd0d9..8cb0fe9 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/(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/app/auth/callback/AuthCallbackContent.tsx b/src/app/auth/callback/AuthCallbackContent.tsx index 12afc63..487cc39 100644 --- a/src/app/auth/callback/AuthCallbackContent.tsx +++ b/src/app/auth/callback/AuthCallbackContent.tsx @@ -49,18 +49,24 @@ 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; + } 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..13aa398 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..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 ?? 'http://localhost:4000/api'; + const backendUrl = getApiBaseUrl(); window.location.href = `${backendUrl}/auth/google`; }, []); diff --git a/src/components/ui/AddressAutocomplete.tsx b/src/components/ui/AddressAutocomplete.tsx index 179ff76..b71a7fe 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, inputValue]); + + useEffect(() => { + void debouncedFetch(inputValue ?? ''); return () => { debouncedFetch.cancel(); }; @@ -148,22 +156,44 @@ 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 +237,34 @@ 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 +296,13 @@ 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..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, + baseUrl: getApiBaseUrl(), responseHandler: 'content-type', }), endpoints: builder => ({ diff --git a/src/lib/axiosBaseQuery.ts b/src/lib/axiosBaseQuery.ts index 58311bb..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,6 +26,8 @@ export const axiosBaseQuery = (): BaseQueryFn< { url, method = 'GET', data, params, headers }, { dispatch, getState }, ) => { + const apiBaseUrl = getApiBaseUrl(); + try { let { csrfToken } = (getState() as RootState).auth; const { isAuthenticated } = (getState() as RootState).auth; @@ -34,12 +37,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, + baseURL: apiBaseUrl, url: '/auth/csrf-token', method: 'GET', withCredentials: true, @@ -56,7 +59,7 @@ export const axiosBaseQuery = (): BaseQueryFn< } const result = await axios({ - baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + baseURL: apiBaseUrl, url, method, data, @@ -90,7 +93,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: apiBaseUrl, url: '/auth/refresh-csrf', method: 'POST', headers: { @@ -110,7 +113,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: apiBaseUrl, url, method, data, diff --git a/src/services/places.ts b/src/services/places.ts index 1b4890f..3b685b1 100644 --- a/src/services/places.ts +++ b/src/services/places.ts @@ -1,4 +1,6 @@ -const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? ''; +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..9f64293 --- /dev/null +++ b/src/utils/api-config.ts @@ -0,0 +1,7 @@ +/** + * Get the API base URL from environment variables + * Keeps the same behavior as before: uses NEXT_PUBLIC_API_BASE_URL with localhost fallback + */ +export function getApiBaseUrl(): string { + return process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:4000/api'; +}