diff --git a/client/package-lock.json b/client/package-lock.json index 4389a8e..9381f67 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@radix-ui/react-slot": "^1.2.3", + "@react-google-maps/api": "^2.20.8", "@tanstack/react-query": "^5.80.7", "@tanstack/react-query-devtools": "^5.80.7", "autoprefixer": "^10.4.21", @@ -466,6 +467,22 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@googlemaps/js-api-loader": { + "version": "1.16.8", + "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz", + "integrity": "sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==", + "license": "Apache-2.0" + }, + "node_modules/@googlemaps/markerclusterer": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.5.3.tgz", + "integrity": "sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw==", + "license": "Apache-2.0", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "supercluster": "^8.0.1" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1313,6 +1330,36 @@ } } }, + "node_modules/@react-google-maps/api": { + "version": "2.20.8", + "resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-2.20.8.tgz", + "integrity": "sha512-wtLYFtCGXK3qbIz1H5to3JxbosPnKsvjDKhqGylXUb859EskhzR7OpuNt0LqdLarXUtZCJTKzPn3BNaekNIahg==", + "license": "MIT", + "dependencies": { + "@googlemaps/js-api-loader": "1.16.8", + "@googlemaps/markerclusterer": "2.5.3", + "@react-google-maps/infobox": "2.20.0", + "@react-google-maps/marker-clusterer": "2.20.0", + "@types/google.maps": "3.58.1", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19", + "react-dom": "^16.8 || ^17 || ^18 || ^19" + } + }, + "node_modules/@react-google-maps/infobox": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/@react-google-maps/infobox/-/infobox-2.20.0.tgz", + "integrity": "sha512-03PJHjohhaVLkX6+NHhlr8CIlvUxWaXhryqDjyaZ8iIqqix/nV8GFdz9O3m5OsjtxtNho09F/15j14yV0nuyLQ==", + "license": "MIT" + }, + "node_modules/@react-google-maps/marker-clusterer": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-2.20.0.tgz", + "integrity": "sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1413,6 +1460,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/google.maps": { + "version": "3.58.1", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", + "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3464,7 +3517,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -4043,6 +4095,15 @@ "node": ">= 0.4" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4554,7 +4615,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4619,6 +4679,12 @@ "node": ">=4.0" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4833,7 +4899,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -6549,6 +6614,15 @@ "node": ">= 6" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/client/package.json b/client/package.json index ce70203..7d3a917 100644 --- a/client/package.json +++ b/client/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@radix-ui/react-slot": "^1.2.3", + "@react-google-maps/api": "^2.20.8", "@tanstack/react-query": "^5.80.7", "@tanstack/react-query-devtools": "^5.80.7", "autoprefixer": "^10.4.21", diff --git a/client/public/icons/Fetch_Icon.png b/client/public/icons/Fetch_Icon.png new file mode 100644 index 0000000..a552747 Binary files /dev/null and b/client/public/icons/Fetch_Icon.png differ diff --git a/client/src/components/ui/calendar.tsx b/client/src/components/ui/calendar.tsx index e43a0c7..c40a5ac 100644 --- a/client/src/components/ui/calendar.tsx +++ b/client/src/components/ui/calendar.tsx @@ -1,19 +1,19 @@ -"use client" +"use client"; -import * as React from "react" import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, -} from "lucide-react" +} from "lucide-react"; +import * as React from "react"; import { + type DayButton, DayPicker, getDefaultClassNames, - type DayButton, -} from "react-day-picker" +} from "react-day-picker"; -import { cn } from "@/lib/utils" -import { Button, buttonVariants } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; function Calendar({ className, @@ -25,22 +25,22 @@ function Calendar({ components, ...props }: React.ComponentProps & { - buttonVariant?: React.ComponentProps["variant"] + buttonVariant?: React.ComponentProps["variant"]; }) { - const defaultClassNames = getDefaultClassNames() + const defaultClassNames = getDefaultClassNames(); return ( svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, - className + className, )} captionLayout={captionLayout} formatters={{ - formatMonthDropdown: (date) => + formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }), ...formatters, }} @@ -48,85 +48,85 @@ function Calendar({ root: cn("w-fit", defaultClassNames.root), months: cn( "flex gap-4 flex-col md:flex-row relative", - defaultClassNames.months + defaultClassNames.months, ), month: cn("flex flex-col w-full gap-4", defaultClassNames.month), nav: cn( "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", - defaultClassNames.nav + defaultClassNames.nav, ), button_previous: cn( buttonVariants({ variant: buttonVariant }), "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", - defaultClassNames.button_previous + defaultClassNames.button_previous, ), button_next: cn( buttonVariants({ variant: buttonVariant }), "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", - defaultClassNames.button_next + defaultClassNames.button_next, ), month_caption: cn( "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", - defaultClassNames.month_caption + defaultClassNames.month_caption, ), dropdowns: cn( "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", - defaultClassNames.dropdowns + defaultClassNames.dropdowns, ), dropdown_root: cn( "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", - defaultClassNames.dropdown_root + defaultClassNames.dropdown_root, ), dropdown: cn( "absolute bg-popover inset-0 opacity-0", - defaultClassNames.dropdown + defaultClassNames.dropdown, ), caption_label: cn( "select-none font-medium", captionLayout === "label" ? "text-sm" : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", - defaultClassNames.caption_label + defaultClassNames.caption_label, ), table: "w-full border-collapse", weekdays: cn("flex", defaultClassNames.weekdays), weekday: cn( "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", - defaultClassNames.weekday + defaultClassNames.weekday, ), week: cn("flex w-full mt-2", defaultClassNames.week), week_number_header: cn( "select-none w-(--cell-size)", - defaultClassNames.week_number_header + defaultClassNames.week_number_header, ), week_number: cn( "text-[0.8rem] select-none text-muted-foreground", - defaultClassNames.week_number + defaultClassNames.week_number, ), day: cn( "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", props.showWeekNumber ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" : "[&:first-child[data-selected=true]_button]:rounded-l-md", - defaultClassNames.day + defaultClassNames.day, ), range_start: cn( "rounded-l-md bg-accent", - defaultClassNames.range_start + defaultClassNames.range_start, ), range_middle: cn("rounded-none", defaultClassNames.range_middle), range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), today: cn( "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", - defaultClassNames.today + defaultClassNames.today, ), outside: cn( "text-muted-foreground aria-selected:text-muted-foreground", - defaultClassNames.outside + defaultClassNames.outside, ), disabled: cn( "text-muted-foreground opacity-50", - defaultClassNames.disabled + defaultClassNames.disabled, ), hidden: cn("invisible", defaultClassNames.hidden), ...classNames, @@ -140,13 +140,13 @@ function Calendar({ className={cn(className)} {...props} /> - ) + ); }, Chevron: ({ className, orientation, ...props }) => { if (orientation === "left") { return ( - ) + ); } if (orientation === "right") { @@ -155,28 +155,28 @@ function Calendar({ className={cn("size-4", className)} {...props} /> - ) + ); } return ( - ) + ); }, DayButton: CalendarDayButton, WeekNumber: ({ children, ...props }) => { return ( -
+
{children}
- ) + ); }, ...components, }} {...props} /> - ) + ); } function CalendarDayButton({ @@ -185,12 +185,12 @@ function CalendarDayButton({ modifiers, ...props }: React.ComponentProps) { - const defaultClassNames = getDefaultClassNames() + const defaultClassNames = getDefaultClassNames(); - const ref = React.useRef(null) + const ref = React.useRef(null); React.useEffect(() => { - if (modifiers.focused) ref.current?.focus() - }, [modifiers.focused]) + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); return ( + {/* Dropdown Menu */} +
+ + Walking Location + + + Vet + +
+
+ + {/* Account with Dropdown */} +
+ + {/* Dropdown Menu */} +
+ + Profile + + +
+ +
+
+ + + {/* Right Section: Sign Up Button */} + +
+ + ); +} diff --git a/client/src/hooks/dog-parks.ts b/client/src/hooks/dog-parks.ts new file mode 100644 index 0000000..330387c --- /dev/null +++ b/client/src/hooks/dog-parks.ts @@ -0,0 +1,81 @@ +import { + useMutation, + useQuery, + useQueryClient, + UseQueryOptions, +} from "@tanstack/react-query"; + +import api from "@/lib/api"; + +export interface DogPark { + id: number; + parkName: string; + streetNo: string; + streetName: string; + suburb: string; + state: string; + postcode: string; + country: string; +} + +export interface SavedDogPark { + id: number; + userName: string; + parkId: DogPark; +} + +export const useDogParks = ( + args?: Omit, "queryKey" | "queryFn">, +) => { + return useQuery({ + ...args, + queryKey: ["dog-parks"], + queryFn: () => + api.get("/api/dog/parks/").then((res) => res.data), + }); +}; + +export const useDogPark = ( + id: number, + args?: Omit, "queryKey" | "queryFn">, +) => { + return useQuery({ + ...args, + queryKey: ["dog-park", id], + queryFn: () => + api.get(`/api/dog/parks/${id}/`).then((res) => res.data), + enabled: !!id, + }); +}; + +export const useSavedDogParks = ( + args?: Omit, "queryKey" | "queryFn">, +) => { + return useQuery({ + ...args, + queryKey: ["saved-dog-parks"], + queryFn: () => + api.get("/api/dog/saved/").then((res) => res.data), + }); +}; + +export const useSaveDogPark = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { userName: string; parkId: number }) => + api.post("/api/dog/saved/", data).then((res) => res.data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["saved-dog-parks"] }); + }, + }); +}; + +export const useUnsaveDogPark = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => api.delete(`/api/dog/saved/${id}/`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["saved-dog-parks"] }); + }, + }); +}; diff --git a/client/src/hooks/vets.ts b/client/src/hooks/vets.ts new file mode 100644 index 0000000..6c1c78e --- /dev/null +++ b/client/src/hooks/vets.ts @@ -0,0 +1,35 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import api from "@/lib/api"; + +export interface VetClinic { + id: number; + clinicName: string; + phoneNumber: string; + email: string; + address: string; +} + +export const useVetClinics = ( + args?: Omit, "queryKey" | "queryFn">, +) => { + return useQuery({ + ...args, + queryKey: ["vet-clinics"], + queryFn: () => + api.get("/api/vet/clinics/").then((res) => res.data), + }); +}; + +export const useVetClinic = ( + id: number, + args?: Omit, "queryKey" | "queryFn">, +) => { + return useQuery({ + ...args, + queryKey: ["vet-clinic", id], + queryFn: () => + api.get(`/api/vet/clinics/${id}/`).then((res) => res.data), + enabled: !!id, + }); +}; diff --git a/client/src/pages/about-us.tsx b/client/src/pages/about-us.tsx index e006925..f8d7d6b 100644 --- a/client/src/pages/about-us.tsx +++ b/client/src/pages/about-us.tsx @@ -1,3 +1,14 @@ -export default function Default() { - return

hello, welcome to the about-us page

; +import { NavigationBar } from "@/components/ui/navigation-bar"; + +export default function AboutUsPage() { + return ( + <> + +
+
+

About Us

+
+
+ + ); } diff --git a/client/src/pages/dog-parks.tsx b/client/src/pages/dog-parks.tsx index 4b586cc..cfba892 100644 --- a/client/src/pages/dog-parks.tsx +++ b/client/src/pages/dog-parks.tsx @@ -1,3 +1,287 @@ -export default function Default() { - return

hello, welcome to the dog-parks page

; +"use client"; + +import { + GoogleMap, + InfoWindow, + LoadScript, + Marker, +} from "@react-google-maps/api"; +import { MapPin, Star, StarOff } from "lucide-react"; +import Head from "next/head"; +import { useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { NavigationBar } from "@/components/ui/navigation-bar"; +import { + DogPark, + useDogParks, + useSavedDogParks, + useSaveDogPark, + useUnsaveDogPark, +} from "@/hooks/dog-parks"; + +const containerStyle = { + width: "100%", + height: "600px", +}; + +const defaultCenter = { + lat: -37.8136, + lng: 144.9631, +}; + +export default function DogParksPage() { + const [selectedPark, setSelectedPark] = useState(null); + const { data: dogParks, isLoading } = useDogParks(); + const { data: savedParks } = useSavedDogParks(); + const savePark = useSaveDogPark(); + const unsavePark = useUnsaveDogPark(); + + // Get saved park IDs + const savedParkIds = useMemo(() => { + return new Set(savedParks?.map((sp) => sp.parkId.id) || []); + }, [savedParks]); + + // Simple geocoding function - in production, use Google Geocoding API + const getParkCoordinates = (park: DogPark): { lat: number; lng: number } => { + // This is a placeholder - in production, you should use Google Geocoding API + // For now, return default center with small random offset + // TODO: Use park parameter when implementing actual geocoding + void park; // Suppress unused variable warning + const offset = Math.random() * 0.1 - 0.05; + return { + lat: defaultCenter.lat + offset, + lng: defaultCenter.lng + offset, + }; + }; + + const parkLocations = useMemo(() => { + if (!dogParks) return []; + return dogParks + .map((park) => { + const coords = getParkCoordinates(park); + return coords ? { park, coords } : null; + }) + .filter( + ( + item, + ): item is { park: DogPark; coords: { lat: number; lng: number } } => + item !== null, + ); + }, [dogParks]); + + const handleToggleBookmark = (park: DogPark) => { + if (savedParkIds.has(park.id)) { + // Find saved park entry and unsave it + const savedPark = savedParks?.find((sp) => sp.parkId.id === park.id); + if (savedPark) { + unsavePark.mutate(savedPark.id); + } + } else { + // Save the park + // In production, get userName from auth context + savePark.mutate({ userName: "current_user", parkId: park.id }); + } + }; + + const formatAddress = (park: DogPark): string => { + return `${park.streetNo} ${park.streetName}, ${park.suburb}, ${park.state} ${park.postcode}, ${park.country}`; + }; + + const googleMapsApiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; + + return ( + <> + + Walking Locations - Fetch! + + +
+
+
+

+ Walking Locations +

+
+ {savedParks && savedParks.length > 0 && ( + + {savedParks.length} saved location + {savedParks.length !== 1 ? "s" : ""} + + )} +
+
+ +
+ {/* Map Section */} +
+ {googleMapsApiKey ? ( + + + {parkLocations.map(({ park, coords }) => ( + setSelectedPark(park)} + title={park.parkName} + icon={ + savedParkIds.has(park.id) + ? { + path: "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z", + fillColor: "#fbbf24", + fillOpacity: 1, + strokeColor: "#f59e0b", + strokeWeight: 2, + scale: 0.8, + } + : undefined + } + /> + ))} + {selectedPark && + parkLocations.find( + (item) => item.park.id === selectedPark.id, + ) && ( + item.park.id === selectedPark.id, + )!.coords + } + onCloseClick={() => setSelectedPark(null)} + > +
+

+ {selectedPark.parkName} +

+

+ {formatAddress(selectedPark)} +

+
+
+ )} +
+
+ ) : ( +
+

+ Google Maps API key not configured. Please set + NEXT_PUBLIC_GOOGLE_MAPS_API_KEY +

+
+ )} +
+ + {/* Park List Section */} +
+
+

+ Dog Parks +

+
+ {isLoading ? ( +
+ Loading parks... +
+ ) : dogParks && dogParks.length > 0 ? ( +
+ {dogParks.map((park) => { + const isSaved = savedParkIds.has(park.id); + return ( +
setSelectedPark(park)} + > +
+

+ {park.parkName} +

+ +
+
+
+ + {formatAddress(park)} +
+
+
+ ); + })} +
+ ) : ( +
+ No dog parks found +
+ )} + + {/* Saved Parks Section */} + {savedParks && savedParks.length > 0 && ( +
+

+ Saved Locations +

+ {savedParks.map((savedPark) => { + const park = savedPark.parkId; + return ( +
+
+

+ {park.parkName} +

+ +
+
+
+ + {formatAddress(park)} +
+
+
+ ); + })} +
+ )} +
+
+
+
+ + ); } diff --git a/client/src/pages/health-records.tsx b/client/src/pages/health-records.tsx index 9308f79..cca40d9 100644 --- a/client/src/pages/health-records.tsx +++ b/client/src/pages/health-records.tsx @@ -1,3 +1,16 @@ -export default function Default() { - return

hello, welcome to the health-records page

; +import { NavigationBar } from "@/components/ui/navigation-bar"; + +export default function HealthRecordsPage() { + return ( + <> + +
+
+

+ Health Records +

+
+
+ + ); } diff --git a/client/src/pages/home.tsx b/client/src/pages/home.tsx index 412b66a..a8a7693 100644 --- a/client/src/pages/home.tsx +++ b/client/src/pages/home.tsx @@ -1,3 +1,16 @@ -export default function Default() { - return

hello, welcome to the home page

; +import { NavigationBar } from "@/components/ui/navigation-bar"; + +export default function HomePage() { + return ( + <> + +
+
+

+ Welcome to the Home Page +

+
+
+ + ); } diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index c051281..4c196bc 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -5,6 +5,7 @@ import { usePings } from "@/hooks/pings"; import { cn } from "@/lib/utils"; import { Button } from "../components/ui/button"; +import { NavigationBar } from "../components/ui/navigation-bar"; const fontSans = FontSans({ subsets: ["latin"], @@ -18,19 +19,22 @@ export default function Home() { }); return ( -
-

Test title

- -

- Response from server: {data as string} -

-
+ <> + +
+

Test title

+ +

+ Response from server: {data as string} +

+
+ ); } diff --git a/client/src/pages/landing.tsx b/client/src/pages/landing.tsx index f3b3462..0bd118b 100644 --- a/client/src/pages/landing.tsx +++ b/client/src/pages/landing.tsx @@ -1,3 +1,16 @@ -export default function Default() { - return

hello, welcome to the landing page

; +import { NavigationBar } from "@/components/ui/navigation-bar"; + +export default function LandingPage() { + return ( + <> + +
+
+

+ Welcome to Fetch! +

+
+
+ + ); } diff --git a/client/src/pages/pet.tsx b/client/src/pages/pet.tsx index ddd68da..92ca91e 100644 --- a/client/src/pages/pet.tsx +++ b/client/src/pages/pet.tsx @@ -1,3 +1,14 @@ -export default function Default() { - return

hello, welcome to the pet page

; +import { NavigationBar } from "@/components/ui/navigation-bar"; + +export default function PetPage() { + return ( + <> + +
+
+

Pet

+
+
+ + ); } diff --git a/client/src/pages/profile.tsx b/client/src/pages/profile.tsx index 7a75048..a145b31 100644 --- a/client/src/pages/profile.tsx +++ b/client/src/pages/profile.tsx @@ -1,3 +1,14 @@ -export default function Default() { - return

hello, welcome to the profile page

; +import { NavigationBar } from "@/components/ui/navigation-bar"; + +export default function ProfilePage() { + return ( + <> + +
+
+

Profile

+
+
+ + ); } diff --git a/client/src/pages/registration.tsx b/client/src/pages/registration.tsx index 481a20f..ffd1882 100644 --- a/client/src/pages/registration.tsx +++ b/client/src/pages/registration.tsx @@ -1,3 +1,14 @@ -export default function Default() { - return

hello, welcome to the registration page

; +import { NavigationBar } from "@/components/ui/navigation-bar"; + +export default function RegistrationPage() { + return ( + <> + +
+
+

Registration

+
+
+ + ); } diff --git a/client/src/pages/shop.tsx b/client/src/pages/shop.tsx index e7f9c06..eb173a4 100644 --- a/client/src/pages/shop.tsx +++ b/client/src/pages/shop.tsx @@ -1,3 +1,14 @@ -export default function Default() { - return

hello, welcome to the shop page

; +import { NavigationBar } from "@/components/ui/navigation-bar"; + +export default function ShopPage() { + return ( + <> + +
+
+

Shop

+
+
+ + ); } diff --git a/client/src/pages/tasks.tsx b/client/src/pages/tasks.tsx index 5bc0fea..836d640 100644 --- a/client/src/pages/tasks.tsx +++ b/client/src/pages/tasks.tsx @@ -1,3 +1,14 @@ -export default function Default() { - return

hello, welcome to the tasks page

; +import { NavigationBar } from "@/components/ui/navigation-bar"; + +export default function TasksPage() { + return ( + <> + +
+
+

Tasks

+
+
+ + ); } diff --git a/client/src/pages/test/home_with_navbar.tsx b/client/src/pages/test/home_with_navbar.tsx new file mode 100644 index 0000000..e69de29 diff --git a/client/src/pages/vets.tsx b/client/src/pages/vets.tsx index bca576c..a65d27a 100644 --- a/client/src/pages/vets.tsx +++ b/client/src/pages/vets.tsx @@ -1,3 +1,191 @@ -export default function Default() { - return

hello, welcome to the vets page

; +"use client"; + +import { + GoogleMap, + InfoWindow, + LoadScript, + Marker, +} from "@react-google-maps/api"; +import { ExternalLink,Mail, MapPin, Phone } from "lucide-react"; +import Head from "next/head"; +import { useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { NavigationBar } from "@/components/ui/navigation-bar"; +import { useVetClinics, VetClinic } from "@/hooks/vets"; + +const containerStyle = { + width: "100%", + height: "600px", +}; + +const defaultCenter = { + lat: -37.8136, + lng: 144.9631, +}; + +export default function VetsPage() { + const [selectedVet, setSelectedVet] = useState(null); + const { data: vetClinics, isLoading } = useVetClinics(); + + // Simple geocoding function - in production, use Google Geocoding API + const getVetCoordinates = (address: string): { lat: number; lng: number } => { + // This is a placeholder - in production, you should use Google Geocoding API + // For now, return default center with small random offset + // TODO: Use address parameter when implementing actual geocoding + void address; // Suppress unused variable warning + const offset = Math.random() * 0.1 - 0.05; + return { + lat: defaultCenter.lat + offset, + lng: defaultCenter.lng + offset, + }; + }; + + const vetLocations = useMemo(() => { + if (!vetClinics) return []; + return vetClinics + .map((vet) => { + const coords = getVetCoordinates(vet.address); + return coords ? { vet, coords } : null; + }) + .filter( + ( + item, + ): item is { vet: VetClinic; coords: { lat: number; lng: number } } => + item !== null, + ); + }, [vetClinics]); + + const googleMapsApiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; + + return ( + <> + + Find Vets - Fetch! + + +
+
+

+ Find Nearby Vets +

+ +
+ {/* Map Section */} +
+ {googleMapsApiKey ? ( + + + {vetLocations.map(({ vet, coords }) => ( + setSelectedVet(vet)} + title={vet.clinicName} + /> + ))} + {selectedVet && + vetLocations.find( + (item) => item.vet.id === selectedVet.id, + ) && ( + item.vet.id === selectedVet.id, + )!.coords + } + onCloseClick={() => setSelectedVet(null)} + > +
+

+ {selectedVet.clinicName} +

+

+ {selectedVet.address} +

+
+
+ )} +
+
+ ) : ( +
+

+ Google Maps API key not configured. Please set + NEXT_PUBLIC_GOOGLE_MAPS_API_KEY +

+
+ )} +
+ + {/* Vet List Section */} +
+

+ Vet Clinics +

+ {isLoading ? ( +
Loading vets...
+ ) : vetClinics && vetClinics.length > 0 ? ( +
+ {vetClinics.map((vet) => ( +
setSelectedVet(vet)} + > +

+ {vet.clinicName} +

+
+
+ + {vet.address} +
+
+ + {vet.phoneNumber} +
+
+ + {vet.email} +
+
+ +
+ ))} +
+ ) : ( +
+ No vet clinics found +
+ )} +
+
+
+
+ + ); } diff --git a/server/dog/urls.py b/server/dog/urls.py index fc6ca9c..c8f5b20 100644 --- a/server/dog/urls.py +++ b/server/dog/urls.py @@ -4,4 +4,8 @@ app_name = "dog" urlpatterns = [ path("", views.dog_home, name="dog-home"), + path("parks/", views.DogParkList.as_view(), name="dog-park-list"), + path("parks//", views.DogParkDetail.as_view(), name="dog-park-detail"), + path("saved/", views.saved_dog_parks, name="saved-dog-parks"), + path("saved//", views.saved_dog_park_detail, name="saved-dog-park-detail"), ] diff --git a/server/dog/views.py b/server/dog/views.py index e56405d..dd79aac 100644 --- a/server/dog/views.py +++ b/server/dog/views.py @@ -1,5 +1,53 @@ from django.shortcuts import render +from rest_framework import generics, permissions, status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response +from .models import DogPark, SavedDogPark +from .serializers import DogParkSerializer, SavedDogParkSerializer + # Create your views here. def dog_home(request): - return render(request, 'dog/dog_home.html') \ No newline at end of file + return render(request, 'dog/dog_home.html') + + +class DogParkList(generics.ListAPIView): + """ViewSet for listing all dog parks""" + permission_classes = [permissions.AllowAny] + queryset = DogPark.objects.all() + serializer_class = DogParkSerializer + + +class DogParkDetail(generics.RetrieveAPIView): + """ViewSet for retrieving a specific dog park""" + permission_classes = [permissions.AllowAny] + queryset = DogPark.objects.all() + serializer_class = DogParkSerializer + + +@api_view(['GET', 'POST']) +@permission_classes([permissions.AllowAny]) +def saved_dog_parks(request): + """API endpoint to list or create saved dog parks""" + if request.method == 'GET': + saved_parks = SavedDogPark.objects.all() + serializer = SavedDogParkSerializer(saved_parks, many=True) + return Response(serializer.data) + elif request.method == 'POST': + serializer = SavedDogParkSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +@api_view(['DELETE']) +@permission_classes([permissions.AllowAny]) +def saved_dog_park_detail(request, pk): + """API endpoint to delete a saved dog park""" + try: + saved_park = SavedDogPark.objects.get(pk=pk) + saved_park.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except SavedDogPark.DoesNotExist: + return Response({'error': 'Saved dog park not found'}, status=status.HTTP_404_NOT_FOUND) \ No newline at end of file diff --git a/server/landing_page/admin.py b/server/landing_page/admin.py index 190693f..64e4327 100644 --- a/server/landing_page/admin.py +++ b/server/landing_page/admin.py @@ -3,4 +3,4 @@ # Register your models here. admin.site.register(Pricing) -admin.site.register(Feature) +admin.site.register(Feature) \ No newline at end of file diff --git a/server/server/urls.py b/server/server/urls.py index 53333e8..ae7d55e 100644 --- a/server/server/urls.py +++ b/server/server/urls.py @@ -26,6 +26,8 @@ path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("api/healthcheck/", include("healthcheck.urls")), path("api/user/", include("user_profile.urls")), + path("api/vet/", include("vet.urls")), + path("api/dog/", include("dog.urls")), path("pet/", include("pet.urls")), path("tasks/", include("tasks.urls")), path("landing/", include("landing_page.urls")), diff --git a/server/vet/urls.py b/server/vet/urls.py index 0458c45..356f5fc 100644 --- a/server/vet/urls.py +++ b/server/vet/urls.py @@ -4,4 +4,6 @@ app_name = "vet" urlpatterns = [ path("", views.vet_home, name="vet-home"), + path("clinics/", views.VetClinicList.as_view(), name="vet-clinic-list"), + path("clinics//", views.VetClinicDetail.as_view(), name="vet-clinic-detail"), ] diff --git a/server/vet/views.py b/server/vet/views.py index 2c8aa80..c5b28f9 100644 --- a/server/vet/views.py +++ b/server/vet/views.py @@ -1,4 +1,46 @@ from django.shortcuts import render +from rest_framework import generics, permissions +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response +from .models import VetClinic +from .serializers import VetClinicSerializer + + # Create your views here. def vet_home(request): - return render(request, 'vet/vet_home.html') \ No newline at end of file + return render(request, 'vet/vet_home.html') + + +@api_view(['GET']) +@permission_classes([permissions.AllowAny]) +def vet_clinic_list(request): + """API endpoint to list all vet clinics""" + clinics = VetClinic.objects.all() + serializer = VetClinicSerializer(clinics, many=True) + return Response(serializer.data) + + +@api_view(['GET']) +@permission_classes([permissions.AllowAny]) +def vet_clinic_detail(request, pk): + """API endpoint to get a specific vet clinic""" + try: + clinic = VetClinic.objects.get(pk=pk) + serializer = VetClinicSerializer(clinic) + return Response(serializer.data) + except VetClinic.DoesNotExist: + return Response({'error': 'Vet clinic not found'}, status=404) + + +class VetClinicList(generics.ListAPIView): + """ViewSet for listing all vet clinics""" + permission_classes = [permissions.AllowAny] + queryset = VetClinic.objects.all() + serializer_class = VetClinicSerializer + + +class VetClinicDetail(generics.RetrieveAPIView): + """ViewSet for retrieving a specific vet clinic""" + permission_classes = [permissions.AllowAny] + queryset = VetClinic.objects.all() + serializer_class = VetClinicSerializer \ No newline at end of file