From 37a6be350430c284d59ba7d8cec5eecb302485a7 Mon Sep 17 00:00:00 2001 From: mark Date: Sat, 17 May 2025 22:14:50 +0800 Subject: [PATCH] refactor: migrate to new API service and update search functionality --- .env | 6 + package-lock.json | 132 +++++++++++++-- package.json | 1 + src/App.tsx | 4 +- src/components/AdminDashboard.tsx | 4 +- src/components/Migrants.tsx | 4 +- src/components/SearchForm.tsx | 261 +++++++++++------------------- src/components/SearchResults.tsx | 30 ++-- src/components/SearchSection.tsx | 94 +++++++++-- src/components/ui/alert.tsx | 66 ++++++++ src/pages/MigrantProfilePage.tsx | 161 +++++++++++++++--- src/services/apiService.ts | 66 ++++++++ src/services/migrantService.ts | 142 ---------------- src/services/searchService.ts | 137 ++++++++++++++++ src/types/search.ts | 95 +++++++++-- vite.config.ts | 10 ++ 16 files changed, 823 insertions(+), 390 deletions(-) create mode 100644 .env create mode 100644 src/components/ui/alert.tsx create mode 100644 src/services/apiService.ts delete mode 100644 src/services/migrantService.ts create mode 100644 src/services/searchService.ts diff --git a/.env b/.env new file mode 100644 index 0000000..236e545 --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +# .env +VITE_API_URL=http://localhost:8000 + +# You can also create environment-specific files: +# .env.development +# .env.production diff --git a/package-lock.json b/package-lock.json index e58312e..59dc246 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-toast": "^1.2.13", "@tailwindcss/vite": "^4.1.6", + "axios": "^1.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.11.0", @@ -2548,6 +2549,11 @@ "node": ">=10" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -2585,6 +2591,16 @@ "postcss": "^8.1.0" } }, + "node_modules/axios": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2678,7 +2694,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -2793,6 +2808,17 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2900,6 +2926,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2926,7 +2960,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -2973,7 +3006,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -2982,7 +3014,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -2991,7 +3022,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -2999,6 +3029,20 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", @@ -3453,6 +3497,58 @@ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3527,7 +3623,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3545,7 +3640,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -3577,7 +3671,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -3614,7 +3707,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -3646,7 +3738,20 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, "engines": { "node": ">= 0.4" }, @@ -3658,7 +3763,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -4152,7 +4256,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -4550,6 +4653,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index 3df0450..5a12e20 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-toast": "^1.2.13", "@tailwindcss/vite": "^4.1.6", + "axios": "^1.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.11.0", diff --git a/src/App.tsx b/src/App.tsx index 8ec8cbc..732be04 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,4 @@ import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; -import HomePage from "./pages/HomePage"; import MigrantProfilePage from "./pages/MigrantProfilePage"; import NotFoundPage from "./pages/NotFoundPage"; import "./App.css"; @@ -7,6 +6,7 @@ import LoginPage from "./components/LoginPage"; import Migrants from "./components/Migrants"; import ProfileSettings from "./components/ui/ProfileSettings"; import AdminDashboardPage from "./pages/AdminDashboardPage"; +import HomePage from "./pages/HomePage"; function App() { return ( @@ -17,7 +17,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index bc2e9b7..61aae16 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -29,7 +29,7 @@ export default function AdminDashboard() { // Check if user is authenticated const token = localStorage.getItem("adminToken") if (!token) { - navigate("/admin/login") + navigate("/admin") return } @@ -62,7 +62,7 @@ export default function AdminDashboard() { localStorage.removeItem("adminToken") localStorage.removeItem("adminNavigation") // Clear navigation flag on logout - navigate("/admin/login") + navigate("/login") } diff --git a/src/components/Migrants.tsx b/src/components/Migrants.tsx index 4345a2c..59b765e 100644 --- a/src/components/Migrants.tsx +++ b/src/components/Migrants.tsx @@ -46,14 +46,14 @@ export default function Migrants() { localStorage.removeItem("adminNavigation") // Clear navigation flag on logout - navigate("/admin/login") + navigate("/login") } // Check authentication and load data useEffect(() => { const token = localStorage.getItem("adminToken") if (!token) { - navigate("/admin/login") + navigate("/login") return } diff --git a/src/components/SearchForm.tsx b/src/components/SearchForm.tsx index 7301637..000527a 100644 --- a/src/components/SearchForm.tsx +++ b/src/components/SearchForm.tsx @@ -1,69 +1,55 @@ "use client"; -import { useState, type ChangeEvent, type FormEvent } from "react"; +import { useState, useEffect, type ChangeEvent, type FormEvent } from "react"; import type { SearchParams } from "@/types/search"; import { Button } from "@/components/ui/button"; import { Search } from "lucide-react"; -// Mock data for autocomplete -const ITALIAN_REGIONS = [ - "Abruzzo", - "Basilicata", - "Calabria", - "Campania", - "Emilia-Romagna", - "Friuli-Venezia Giulia", - "Lazio", - "Liguria", - "Lombardy", - "Marche", - "Molise", - "Piedmont", - "Puglia", - "Sardinia", - "Sicily", - "Tuscany", - "Trentino-Alto Adige", - "Umbria", - "Valle d'Aosta", - "Veneto", -]; - -const NT_SETTLEMENTS = [ - "Darwin", - "Alice Springs", - "Katherine", - "Tennant Creek", - "Nhulunbuy", - "Jabiru", - "Yulara", - "Borroloola", - "Pine Creek", - "Adelaide River", -]; - interface SearchFormProps { onSearch: (params: SearchParams) => void; + onReset?: () => void; } -const SearchForm = ({ onSearch }: SearchFormProps) => { - const [formData, setFormData] = useState({ - firstName: "", - lastName: "", - ageAtMigration: "", - yearOfArrival: "", - regionOfOrigin: "all", - settlementLocation: "all", - }); +// Default form data +const defaultData: SearchParams = { + firstName: "", + lastName: "", + ageAtMigration: "", + yearOfArrival: "", + regionOfOrigin: "all", + settlementLocation: "all", +}; - const handleInputChange = (e: ChangeEvent) => { - const { name, value } = e.target; - setFormData((prev) => ({ ...prev, [name]: value })); - }; +// Form field definitions to make the JSX cleaner +const textFields = [ + { id: "firstName", label: "First Name (Christian Name)", type: "text" }, + { id: "lastName", label: "Last Name (Surname)", type: "text" }, + { id: "ageAtMigration", label: "Age at Migration", type: "number", min: 0, max: 120 }, + { + id: "yearOfArrival", + label: "Date of Arrival in NT (Year)", + type: "number", + min: 1800, + max: new Date().getFullYear() + }, +]; - const handleSelectChange = (e: ChangeEvent) => { +const selectFields = [ + { id: "regionOfOrigin", label: "Region of Origin", options: ["all"] }, + { id: "settlementLocation", label: "Settlement Location", options: ["all"] }, +]; + +const SearchForm = ({ onSearch, onReset }: SearchFormProps) => { + const [formData, setFormData] = useState(defaultData); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(false); + }, []); + + const handleChange = (e: ChangeEvent) => { const { name, value } = e.target; - setFormData((prev) => ({ ...prev, [name]: value })); + setFormData(prevData => ({ ...prevData, [name]: value })); }; const handleSubmit = (e: FormEvent) => { @@ -72,132 +58,64 @@ const SearchForm = ({ onSearch }: SearchFormProps) => { }; const handleReset = () => { - setFormData({ - firstName: "", - lastName: "", - ageAtMigration: "", - yearOfArrival: "", - regionOfOrigin: "all", - settlementLocation: "all", - }); + setFormData(defaultData); + if (onReset) onReset(); }; return (
-
- - -
+ {/* Text and number input fields */} + {textFields.map(({ id, label, type, min, max }) => ( +
+ + +
+ ))} -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
+ {selectFields.map(({ id, label, options }) => ( +
+ + + {isLoading && ( +

Loading {label.toLowerCase()}...

+ )} +
+ ))}
- @@ -213,4 +132,4 @@ const SearchForm = ({ onSearch }: SearchFormProps) => { ); }; -export default SearchForm; +export default SearchForm; \ No newline at end of file diff --git a/src/components/SearchResults.tsx b/src/components/SearchResults.tsx index 5894206..b7223e2 100644 --- a/src/components/SearchResults.tsx +++ b/src/components/SearchResults.tsx @@ -1,6 +1,6 @@ "use client"; -import { Link } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; import type { SearchResult } from "@/types/search"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -18,6 +18,9 @@ export default function SearchResults({ isLoading, hasSearched = false, }: SearchResultsProps) { + const navigate = useNavigate(); + + // Define animation variants const container = { hidden: { opacity: 0 }, show: { @@ -27,7 +30,7 @@ export default function SearchResults({ }, }, }; - + const item = { hidden: { opacity: 0, y: 20 }, show: { opacity: 1, y: 0, transition: { duration: 0.5 } }, @@ -99,16 +102,15 @@ export default function SearchResults({ animate="show" > {results.map((person) => ( - - + navigate(`/migrants/${person.person_id}`)}> +
@@ -122,27 +124,31 @@ export default function SearchResults({
- {person.firstName} {person.lastName} + {person.full_name}

- Arrived {person.yearOfArrival} at age{" "} - {person.ageAtMigration} + {person.migration?.date_of_arrival_nt ? + `Arrived ${new Date(person.migration.date_of_arrival_nt).getFullYear()}` : 'Date unknown'}

From:{" "} - {person.regionOfOrigin}, Italy + {person.place_of_birth || 'Unknown'}, Italy

Settled in:{" "} - {person.settlementLocation}, NT + {person.residence?.town_or_city || 'Unknown'}, NT +

+

+ Occupation:{" "} + {person.occupation || 'Unknown'}

- +
))} diff --git a/src/components/SearchSection.tsx b/src/components/SearchSection.tsx index c86a769..2281faf 100644 --- a/src/components/SearchSection.tsx +++ b/src/components/SearchSection.tsx @@ -4,25 +4,69 @@ import { useState } from "react"; import SearchForm from "./SearchForm"; import SearchResults from "./SearchResults"; import type { SearchParams, SearchResult } from "../types/search"; -import { searchMigrants } from "../services/migrantService"; +import apiService from "../services/apiService"; +import { Loader2 } from "lucide-react"; const SearchSection = () => { const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const [hasSearched, setHasSearched] = useState(false); + const [noResultsFound, setNoResultsFound] = useState(false); const handleSearch = async (params: SearchParams) => { - setIsSearching(true); - try { - const results = await searchMigrants(params); - setSearchResults(results); - setHasSearched(true); - } catch (error) { - console.error("Error searching migrants:", error); - // In a real application, you would handle this error more gracefully - } finally { - setIsSearching(false); + // Check if at least one search parameter is provided (not empty and not 'all') + const hasSearchCriteria = Object.entries(params).some(([_, value]) => + value && value !== "" && value !== "all" + ); + + // If no search criteria provided, don't perform search + if (!hasSearchCriteria) { + setSearchResults([]); + setHasSearched(false); + setNoResultsFound(false); + return; } + + // Show loading indicator + setIsSearching(true); + setSearchResults([]); + + try { + // Display a loading message in the UI before making the API call + // API call with small delay to ensure loader is visible + setTimeout(async () => { + try { + // Use apiService.searchPeople with exact matching + const results = await apiService.searchPeople(params); + setSearchResults(results); + setHasSearched(true); + + // Check if the results array is empty + setNoResultsFound(results.length === 0); + + // Log successful search + console.log(`Found ${results.length} exact match results using apiService.searchPeople`); + } catch (error) { + console.error("Error searching people:", error); + // Display user-friendly error message + setSearchResults([]); + setHasSearched(true); + setNoResultsFound(true); + } finally { + setIsSearching(false); + } + }, 500); // Short delay to ensure loader is visible + } catch (error) { + console.error("Unexpected error:", error); + setIsSearching(false); + setHasSearched(true); + } + }; + + const handleReset = () => { + setSearchResults([]); + setHasSearched(false); + setNoResultsFound(false); }; return ( @@ -31,9 +75,31 @@ const SearchSection = () => {

Search Historical Records

- - {(isSearching || hasSearched) && ( - + + {isSearching && ( +
+ + Loading... +
+ )} + {hasSearched && !isSearching && noResultsFound && ( +
+

No Results Found

+

+ We couldn't find any records matching your search criteria. +

+

+ Try adjusting your search filters or using fewer criteria to broaden your results. +

+
+ )} + + {(hasSearched || isSearching) && ( + )}
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/pages/MigrantProfilePage.tsx b/src/pages/MigrantProfilePage.tsx index dc5d426..de33b41 100644 --- a/src/pages/MigrantProfilePage.tsx +++ b/src/pages/MigrantProfilePage.tsx @@ -2,48 +2,173 @@ import { useEffect, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import { getMigrantById } from "../services/migrantService"; +import apiService from "../services/apiService"; import type { MigrantProfile } from "../types/migrant"; import MigrantProfileComponent from "../components/MigrantProfileComponent"; -import LoadingSpinner from "../components/LoadingSpinner"; +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +// Helper function to calculate age at migration +const calculateAgeAtMigration = (birthDate: string | null | undefined, migrationDate: string | null | undefined): number => { + if (!birthDate || !migrationDate) return 0; + + try { + const normalizedBirthDate = birthDate.includes('.') ? birthDate.split('.')[0] + 'Z' : birthDate; + const normalizedMigrationDate = migrationDate.includes('.') ? migrationDate.split('.')[0] + 'Z' : migrationDate; + + const birthYear = new Date(normalizedBirthDate).getFullYear(); + const migrationYear = new Date(normalizedMigrationDate).getFullYear(); + + // Simple year difference calculation + const age = migrationYear - birthYear; + + return age >= 0 ? age : 0; + } catch (error) { + console.error(`Error calculating age: birth=${birthDate}, migration=${migrationDate}`, error); + return 0; + } +}; const MigrantProfilePage = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [migrant, setMigrant] = useState(null); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [retryCount, setRetryCount] = useState(0); + // Fetch migrant data when component mounts or ID changes useEffect(() => { - const fetchMigrant = async () => { + const fetchMigrantData = async () => { + // Reset state when ID changes + setLoading(true); + setMigrant(null); + setError(null); + + if (!id) { + setError('Missing migrant ID'); + setLoading(false); + return; + } + try { - if (id) { - const data = await getMigrantById(id); - if (data) { - setMigrant(data); - } else { - // Migrant not found, redirect to 404 - navigate("/not-found"); - } + // Fetch migrant data from the backend using apiService + const data = await apiService.getRecordById(id); + + if (data) { + // Data successfully retrieved - convert API data to MigrantProfile + const migrantProfile: MigrantProfile = { + id: data.person_id || id, + firstName: data.christian_name || '', + lastName: data.surname || '', + middleName: '', + birthDate: data.date_of_birth || '', + birthPlace: data.place_of_birth || '', + ageAtMigration: calculateAgeAtMigration(data.date_of_birth, data.migration?.date_of_arrival_nt), + yearOfArrival: data.migration?.date_of_arrival_nt ? + new Date(data.migration.date_of_arrival_nt).getFullYear() : 0, + regionOfOrigin: data.place_of_birth || 'Unknown', + settlementLocation: data.residence?.town_or_city || 'Unknown', + occupation: data.occupation || 'Unknown', + deathDate: data.date_of_death || '', + deathPlace: data.residence?.home_at_death || '', + mainPhoto: '', // No photo URL in the API response + biography: data.additional_notes || '', + photos: [], + relatedMigrants: [] + }; + + setMigrant(migrantProfile); + setError(null); + } else { + // No data found for this ID + setMigrant(null); + setError(`No migrant found with ID: ${id}`); } - } catch (error) { - console.error("Error fetching migrant:", error); - navigate("/not-found"); + } catch (error: any) { + // Handle API errors + const errorMessage = + error.response?.data?.message || + error.message || + 'An unexpected error occurred'; + + console.error('Error fetching migrant:', errorMessage); + setError(`Failed to load migrant data: ${errorMessage}`); } finally { setLoading(false); } }; - fetchMigrant(); - }, [id, navigate]); + fetchMigrantData(); + }, [id, retryCount]); // retryCount allows manual retries + // Handle retry button click + const handleRetry = () => { + setRetryCount(prev => prev + 1); + }; + + // Loading state if (loading) { - return ; + return ( +
+ +

Loading migrant profile...

+

Retrieving data from the database

+
+ ); + } + + // Error state + if (error) { + // Check if the error is specifically about not finding a migrant + const isNotFoundError = error.includes('No migrant found'); + + return ( +
+ + + {isNotFoundError ? "Migrant Not Found" : "Error"} + {error} + + +
+ {isNotFoundError && ( +
+

The migrant profile you're looking for could not be found in our database.

+

This might be because:

+
    +
  • The ID provided is incorrect
  • +
  • The record has been removed from the database
  • +
  • The record hasn't been added to the database yet
  • +
+
+ )} + +
+ + {!isNotFoundError && ( + + )} + +
+
+
+ ); } + // No data state (should be caught by error state, but just in case) if (!migrant) { - return null; // This should not happen as we redirect to 404 if migrant is null + return ( +
+

Migrant Profile Not Found

+

The migrant profile you're looking for could not be found.

+ +
+ ); } + // Render migrant profile when data is loaded successfully return ; }; diff --git a/src/services/apiService.ts b/src/services/apiService.ts new file mode 100644 index 0000000..07def72 --- /dev/null +++ b/src/services/apiService.ts @@ -0,0 +1,66 @@ +import axios from "axios"; +import type { SearchResult, SearchParams } from "../types/search"; + +// Load base URL from environment +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; + +// Create Axios instance +const api = axios.create({ + baseURL: API_BASE_URL, + headers: { "Content-Type": "application/json" }, +}); + +// Request interceptor for logging +api.interceptors.request.use((request) => { + const fullUrl = `${request.baseURL || ""}${request.url || ""}`; + console.log("[API] Request URL:", fullUrl); + + if (request.params) { + console.log("[API] Params:", request.params); + } + + if (request.url?.includes("/migrants/")) { + const id = request.url.split("/").pop(); + console.log("[API] Fetching migrant by ID:", id); + } + + return request; +}); + +class ApiService { + /** + * Fetch a single migrant record by ID + */ + async getRecordById(id: string): Promise { + const res = await api.get(`/api/migrants/${id}`); + return res.data.data; + } + + /** + * Search for people based on filters. + * Filters out empty and "all" values. + */ + async searchPeople(params: SearchParams): Promise { + const filtered = Object.fromEntries( + Object.entries(params) + .filter(([_, value]) => value && value !== "all") + .map(([key, value]) => [key, value.toString()]) + ); + + if (Object.keys(filtered).length === 0) return []; + + try { + const res = await api.get("/api/persons/search", { + params: { ...filtered, exactMatch: "true" }, + }); + + return res.data.success && res.data.data ? res.data.data.data : []; + } catch (err) { + const message = + err instanceof Error ? err.message : "Unknown error during search"; + throw new Error(`[API] Failed to search people: ${message}`); + } + } +} + +export default new ApiService(); diff --git a/src/services/migrantService.ts b/src/services/migrantService.ts deleted file mode 100644 index 74a12f1..0000000 --- a/src/services/migrantService.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { MigrantProfile } from "../types/migrant" -import type { SearchParams, SearchResult } from "../types/search" - -// Mock data for demonstration purposes -// In a real application, this would be fetched from an API -const migrants: MigrantProfile[] = [ - { - id: "1", - firstName: "Antonio", - lastName: "Rossi", - middleName: "Giuseppe", - birthDate: "January 12, 1861", - birthPlace: "Palermo, Sicily", - ageAtMigration: 24, - yearOfArrival: 1885, - regionOfOrigin: "Sicily", - settlementLocation: "Darwin", - occupation: "Fisherman", - deathDate: "March 3, 1942", - deathPlace: "Darwin, Northern Territory", - mainPhoto: "/placeholder.jpg", - biography: `Antonio Giuseppe Rossi was born in Palermo, Sicily, in 1861 to a family of fishermen. Growing up along the Mediterranean coast, he learned the fishing trade from his father and grandfather, developing skills that would later serve him well in Australia.\n - In 1885, at the age of 24, Antonio left Italy seeking new opportunities. The fishing industry in Sicily was becoming increasingly competitive, and stories of Australia's abundant waters and opportunities had reached Italian shores. He arrived in Darwin with little more than his fishing knowledge and a determination to succeed.\n - Antonio quickly established himself in Darwin's small but growing fishing community. He was known for his innovative fishing techniques and his ability to predict weather patterns. Within five years of his arrival, he had saved enough money to purchase his own boat, which he named "Sicilia" in honor of his homeland.\n - In 1890, Antonio married Maria Bianchi, a fellow Italian immigrant from Calabria. Together they had six children, all of whom grew up helping with the family fishing business. Antonio and Maria were instrumental in establishing Darwin's Italian community, often hosting gatherings that celebrated Italian culture and traditions.\n - During World War II, Antonio, then in his eighties, witnessed the bombing of Darwin. Despite his age, he helped in the evacuation efforts, using his boat to transport people to safety. He passed away shortly after, in March 1942, leaving behind a legacy as one of Darwin's pioneering Italian settlers.`, - photos: [ - { - url: "/placeholder.jpg", - caption: "Antonio on his fishing boat 'Sicilia'", - year: 1895, - }, - { - url: "/placeholder.jpg", - caption: "Antonio and Maria on their wedding day", - year: 1890, - }, - { - url: "/placeholder.jpg", - caption: "The Rossi family outside their Darwin home", - year: 1910, - }, - ], - relatedMigrants: [ - { - id: "2", - firstName: "Maria", - lastName: "Bianchi", - relationship: "Wife", - photoUrl: "/placeholder.jpg", - }, - ], - }, - { - id: "2", - firstName: "Maria", - lastName: "Bianchi", - ageAtMigration: 19, - yearOfArrival: 1892, - regionOfOrigin: "Calabria", - settlementLocation: "Alice Springs", - occupation: "Seamstress", - mainPhoto: "/placeholder.jpg", - }, - { - id: "3", - firstName: "Giuseppe", - lastName: "Verdi", - ageAtMigration: 32, - yearOfArrival: 1901, - regionOfOrigin: "Veneto", - settlementLocation: "Katherine", - occupation: "Farmer", - mainPhoto: "/placeholder.jpg", - }, -] - -export const searchMigrants = async (params: SearchParams): Promise => { - // In a real application, this would call an API endpoint - // For demonstration, we'll filter the mock data based on the search parameters - - let results = migrants.map((migrant) => ({ - id: migrant.id, - firstName: migrant.firstName, - lastName: migrant.lastName, - ageAtMigration: migrant.ageAtMigration, - yearOfArrival: migrant.yearOfArrival, - regionOfOrigin: migrant.regionOfOrigin, - settlementLocation: migrant.settlementLocation, - photoUrl: migrant.mainPhoto, - })) - - // Filter by first name - if (params.firstName) { - results = results.filter((migrant) => - migrant.firstName.toLowerCase().includes(params.firstName.toString().toLowerCase()), - ) - } - - // Filter by last name - if (params.lastName) { - results = results.filter((migrant) => - migrant.lastName.toLowerCase().includes(params.lastName.toString().toLowerCase()), - ) - } - - // Filter by age at migration - if (params.ageAtMigration) { - results = results.filter((migrant) => migrant.ageAtMigration === Number(params.ageAtMigration)) - } - - // Filter by year of arrival - if (params.yearOfArrival) { - results = results.filter((migrant) => migrant.yearOfArrival === Number(params.yearOfArrival)) - } - - // Filter by region of origin - if (params.regionOfOrigin && params.regionOfOrigin !== "all") { - results = results.filter((migrant) => migrant.regionOfOrigin === params.regionOfOrigin) - } - - // Filter by settlement location - if (params.settlementLocation && params.settlementLocation !== "all") { - results = results.filter((migrant) => migrant.settlementLocation === params.settlementLocation) - } - - // Simulate API delay - await new Promise((resolve) => setTimeout(resolve, 500)) - - return results -} - -export const getMigrantById = async (id: string): Promise => { - // In a real application, this would call an API endpoint - // For demonstration, we'll find the migrant in our mock data - const migrant = migrants.find((m) => m.id === id) - - // Simulate API delay - await new Promise((resolve) => setTimeout(resolve, 300)) - - return migrant || null -} diff --git a/src/services/searchService.ts b/src/services/searchService.ts new file mode 100644 index 0000000..94683ec --- /dev/null +++ b/src/services/searchService.ts @@ -0,0 +1,137 @@ +import axios from "axios"; +import type { SearchParams, SearchResult } from "@/types/search"; +import type { MigrantProfile } from "@/types/migrant"; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; + +const api = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + + +class ApiService { + async getRecords(): Promise { + return api.get('/api/persons/search').then((response) => response.data); + } + +} +export const apiService = new ApiService(); + +export const searchPeople = async (params: SearchParams): Promise => { + const query = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value && value !== "all") { + query.append(key, value.toString()); + } + }); + + try { + const response = await api.get( + `/api/persons/search?${query.toString()}` + ); + + const responseData = response.data; + if (responseData.success && responseData.data) { + return responseData.data.data.map((person: any) => ({ + id: person.person_id || person.id, + firstName: person.christian_name, + lastName: person.surname, + yearOfArrival: person.migration?.date_of_arrival_nt ? + new Date(person.migration.date_of_arrival_nt).getFullYear() : 'Unknown', + ageAtMigration: person.age_at_migration || 'Unknown', + regionOfOrigin: person.place_of_birth || 'Unknown', + settlementLocation: person.residence?.suburb || 'Unknown', + occupation: person.occupation || 'Unknown', + photoUrl: person.photo_url || null + })); + } + + return []; + } catch (error) { + console.error('Error searching people:', error); + throw error; + } +}; + +/** + * Retrieves detailed profile information for a specific migrant by ID + * @param id - The migrant's unique identifier + * @returns Promise containing the migrant's complete profile or null if not found + */ +export const getMigrantById = async (id: string): Promise => { + + try { + console.log(`Making API request to: ${API_BASE_URL}/api/migrants/${id}`); + + const response = await axios.get( + `${API_BASE_URL}/api/migrants/${id}` + ); + + console.log('API Response:', response.status, response.statusText); + console.log('Response data:', response.data); + + const responseData = response.data; + + if (responseData.success && responseData.data) { + console.log('Successfully parsed response data'); + const person = responseData.data; + return { + id: person.person_id || person.id, + firstName: person.christian_name, + lastName: person.surname, + middleName: person.middle_name, + birthDate: person.date_of_birth + ? new Date(person.date_of_birth.split('.')[0] + 'Z').toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : 'Unknown', + birthPlace: person.place_of_birth, + ageAtMigration: person.age_at_migration ? Number(person.age_at_migration) : 0, + yearOfArrival: person.migration?.date_of_arrival_nt ? + new Date(person.migration.date_of_arrival_nt).getFullYear() : 0, + regionOfOrigin: person.place_of_birth || 'Unknown', + settlementLocation: person.residence?.suburb || 'Unknown', + occupation: person.occupation || 'Unknown', + deathDate: person.date_of_death + ? new Date(person.date_of_death.split('.')[0] + 'Z').toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : 'Unknown', + deathPlace: person.residence?.home_at_death || 'Unknown', + mainPhoto: person.photo_url || null, + biography: person.biography || '', + photos: person.photos || [], + relatedMigrants: person.related_persons || [] + }; + } + + return null; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('Axios error in getMigrantById:', { + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + data: error.response?.data + }); + + // If the error is a 404, we can return null to indicate no migrant found + if (error.response?.status === 404) { + console.log('Migrant not found (404 response)'); + return null; + } + } else { + console.error('Non-Axios error fetching migrant profile:', error); + } + throw error; + } +}; + diff --git a/src/types/search.ts b/src/types/search.ts index 5f29b9b..6966895 100644 --- a/src/types/search.ts +++ b/src/types/search.ts @@ -1,19 +1,84 @@ export interface SearchParams { - firstName: string - lastName: string - ageAtMigration: string | number - yearOfArrival: string | number - regionOfOrigin: string - settlementLocation: string + firstName?: string; + lastName?: string; + ageAtMigration?: string; + yearOfArrival?: string; + regionOfOrigin?: string; + settlementLocation?: string; } -export interface SearchResult { - id: string - firstName: string - lastName: string - ageAtMigration: number - yearOfArrival: number - regionOfOrigin: string - settlementLocation: string - photoUrl?: string +// Pagination metadata structure +export interface Pagination { + total: number; + currentPage: number; + totalPages: number; + perPage: number; } + +// Migration details for a person +export interface Migration { + date_of_arrival_aus?: string; + date_of_arrival_nt?: string; + arrival_period?: string; + data_source?: string; +} + +// Naturalization details for a person +export interface Naturalization { + certificate_number?: string; + date_of_naturalization?: string; + previous_nationality?: string; + place_of_naturalization?: string; +} + +// Residence details for a person +export interface Residence { + town_or_city?: string; + home_at_death?: string; +} + +// Family details for a person +export interface Family { + spouse_name?: string; + spouse_origin?: string; + number_of_children?: number; + names_of_children?: string; + additional_notes?: string; +} + +// Internment details for a person +export interface Internment { + was_interned: boolean; + camp_name?: string; + date_of_internment?: string; + date_of_release?: string; + additional_notes?: string; +} + +// A single search result representing a person record +export interface SearchResult { + person_id: string; + surname: string; + christian_name: string; + full_name: string; + date_of_birth?: string; + place_of_birth?: string; + date_of_death?: string; + occupation?: string; + additional_notes?: string; + reference?: string; + id_card_no?: string; + migration: Migration; + naturalization: Naturalization; + residence: Residence; + family: Family; + internment: Internment; +} + +// The overall search response from the API +export interface SearchResponse { + data: SearchResult[]; + pagination: Pagination; + success: boolean; + message: string; +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 41e04e1..f2ef6a6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,4 +11,14 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, + server: { + proxy: { + "/api": { + target: "http://localhost:8000", + changeOrigin: true, + // Don't rewrite the path since Laravel expects /api prefix + // rewrite: (path) => path.replace(/^\/api/, ""), + }, + }, + }, });