diff --git a/frontend/src/AppRoutes.tsx b/frontend/src/AppRoutes.tsx index 8bb18020..e8045f46 100644 --- a/frontend/src/AppRoutes.tsx +++ b/frontend/src/AppRoutes.tsx @@ -1,21 +1,24 @@ import { Route, Routes } from "react-router-dom"; import PlantMatchPage from "./components/plantMatch/PlantMatchPage.tsx"; -import Profile from "./components/profile/Profile.tsx"; +import ExplorePage from "./pages/explorePage/ExplorePage.tsx"; import MyMatchesPage from "./pages/MyMatches/MyMatchesPage.tsx"; +import Profile from "./components/profile/Profile.tsx"; // eslint-disable-next-line react-refresh/only-export-components export const ROUTES = { plantMatch: "/", - profile: "/profile", + explore: "/explorePlants", myMatches: "/my-matches", + profile: "/profile", } as const; function AppRoutes() { return ( } /> - } /> + } /> } /> + } /> ); } diff --git a/frontend/src/components/header/Header.tsx b/frontend/src/components/header/Header.tsx index fdaac430..75cf9c43 100644 --- a/frontend/src/components/header/Header.tsx +++ b/frontend/src/components/header/Header.tsx @@ -6,8 +6,9 @@ import { ROUTES } from "../../AppRoutes"; // Maps each route path to the translation key used for its title const pageTitles: Record = { [ROUTES.plantMatch]: "sidebar.plantMatch", - [ROUTES.profile]: "sidebar.profile", + [ROUTES.explore]: "sidebar.explorePlants", [ROUTES.myMatches]: "sidebar.myMatches", + [ROUTES.profile]: "sidebar.profile", }; export default function Header() { diff --git a/frontend/src/components/sidebar/Sidebar.tsx b/frontend/src/components/sidebar/Sidebar.tsx index 0f208193..03af4072 100644 --- a/frontend/src/components/sidebar/Sidebar.tsx +++ b/frontend/src/components/sidebar/Sidebar.tsx @@ -24,9 +24,9 @@ export default function Sidebar({ isOpen }: SidebarProps) {
  • - + {t("sidebar.explorePlants")} - +
  • diff --git a/frontend/src/hooks/useDebounce.ts b/frontend/src/hooks/useDebounce.ts new file mode 100644 index 00000000..24205b9e --- /dev/null +++ b/frontend/src/hooks/useDebounce.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay = 800): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/frontend/src/hooks/useSessionState.ts b/frontend/src/hooks/useSessionState.ts new file mode 100644 index 00000000..6200f6e1 --- /dev/null +++ b/frontend/src/hooks/useSessionState.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; + +export function useSessionState(key: string, initialValue = "") { + const [value, setValue] = useState(() => { + try { + return sessionStorage.getItem(key) ?? initialValue; + } catch { + return initialValue; + } + }); + + useEffect(() => { + try { + sessionStorage.setItem(key, value); + } catch { + // Storage can fail (private mode, full quota). The app still works, + // the value just won't be remembered. + } + }, [key, value]); + + return [value, setValue] as const; +} diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index 8453d76d..761a5ba1 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -33,6 +33,26 @@ "small": "small", "succulents": "succulent" }, + "explorePage": { + "search": "Search for plants", + "watering": "Watering needs", + "all": "All", + "minimum": "Minimum", + "average": "Average", + "frequent": "Frequent", + "sunlight": "Sun requirements", + "full_sun": "Full sun", + "part_sun": "Part sun", + "part_shade": "Part shade", + "location": "Location", + "indoor": "Indoor", + "outdoor": "Outdoor", + "edibility": "Edibility", + "edible": "Edible", + "poison": "Poisonous", + "reset": "Reset", + "noResults": "No results found" + }, "myMatchesPage": { "guide": "These are your matches:" } diff --git a/frontend/src/i18n/locales/no/translation.json b/frontend/src/i18n/locales/no/translation.json index 03da998b..35a14f64 100644 --- a/frontend/src/i18n/locales/no/translation.json +++ b/frontend/src/i18n/locales/no/translation.json @@ -33,6 +33,26 @@ "small": "liten", "succulents": "sukkulent" }, + "explorePage": { + "search": "Søk etter planter", + "watering": "Vanningsbehov", + "all": "Alle", + "minimum": "Sjeldent", + "average": "Jevnlig", + "frequent": "Ofte", + "sunlight": "Solforhold", + "full_sun": "Full sol", + "part_sun": "Delvis sol", + "part_shade": "Delvis skygge", + "location": "Plassering", + "indoor": "Innendørs", + "outdoor": "Utendørs", + "edibility": "Spiselighet", + "edible": "Spiselig", + "poison": "Giftig", + "reset": "Nullstill", + "noResults": "Ingen resultater funnet" + }, "myMatchesPage": { "guide": "Dette er dine matcher:" } diff --git a/frontend/src/pages/explorePage/ExplorePage.css b/frontend/src/pages/explorePage/ExplorePage.css new file mode 100644 index 00000000..8a74a7b1 --- /dev/null +++ b/frontend/src/pages/explorePage/ExplorePage.css @@ -0,0 +1,158 @@ +.explore-page { + margin-left: var(--sidebar-width); + width: calc(100% - var(--sidebar-width)); + min-height: 100vh; + padding: 40px; + box-sizing: border-box; + + display: flex; + flex-direction: column; + align-items: center; + gap: 30px; + + font-family: Georgia, serif; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.explore-form, +.plant-grid { + width: 100%; +} + +.explore-search input { + width: 100%; + padding: 0.75rem 1rem; + font-size: 1rem; + border: 1px solid var(--border-soft); + border-radius: 8px; + box-sizing: border-box; +} + +.explore-search input:focus { + outline: none; + border-color: var(--dark-green); + box-shadow: 0 0 0 2px var(--dark-green); +} + +.explore-filters { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin: 1rem 0 0; + padding: 0; + border: 0; +} + +.filter { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.filter select { + padding: 0.5rem; + border: 1px solid var(--border-soft); + border-radius: 8px; + min-width: 160px; +} + +.filter select:focus { + outline: none; + border-color: var(--dark-green); + box-shadow: 0 0 0 2px var(--dark-green); +} + +.reset-button { + align-self: flex-start; + width: 74px; + height: 38px; + border: 1px solid var(--border-soft); + border-radius: 8px; + background-color: var(--light-green); + color: var(--text-dark); + font-size: 16px; + cursor: pointer; +} + +.reset-button:hover { + background-color: var(--dark-green); + color: var(--text-light); +} + +.reset-button:focus-within { + outline: none; + border: var(--dark-green); + box-shadow: 0 0 0 2px var(--dark-green); +} + +.plant-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; +} + +.explore-plant-card-placeholder { + aspect-ratio: 3 / 4; + border-radius: 12px; + overflow: hidden; + box-sizing: border-box; + border: 3px solid var(--border-soft); +} + +.explore-plant-card-placeholder:focus-within { + border-color: var(--dark-green); +} + +.plant-card-button { + width: 100%; + height: 100%; + padding: 1rem; + text-align: center; + background: none; + border: none; + outline: none; + font: inherit; + color: inherit; + cursor: pointer; +} + +.plant-card-info { + display: flex; + flex-direction: column; + gap: 0.75rem; + text-align: left; +} + +@media (max-width: 767px) { + .explore-page { + margin-left: 0; + width: 100%; + padding: 20px; + gap: 20px; + } + + .plant-grid { + grid-template-columns: repeat(2, 1fr); + gap: 15px; + } + + .plant-card-button { + padding: 0.5rem; + } + + .plant-card-info { + font-size: 13px; + gap: 0.4rem; + } +} diff --git a/frontend/src/pages/explorePage/ExplorePage.tsx b/frontend/src/pages/explorePage/ExplorePage.tsx new file mode 100644 index 00000000..bef0c339 --- /dev/null +++ b/frontend/src/pages/explorePage/ExplorePage.tsx @@ -0,0 +1,196 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useDebounce } from "../../hooks/useDebounce"; +import { useSessionState } from "../../hooks/useSessionState"; +import "./ExplorePage.css"; + +// prettier-ignore +const plants = [ + { id: 1, name: "Hydrangea", watering: "average", sunlight: "part_shade", indoor: false, edible: false }, + { id: 2, name: "Orchid", watering: "minimum", sunlight: "part_sun", indoor: true, edible: false }, + { id: 3, name: "Fig Tree", watering: "average", sunlight: "full_sun", indoor: true, edible: true }, + { id: 4, name: "Monstera", watering: "average", sunlight: "part_shade", indoor: true, edible: false }, + { id: 5, name: "Peony", watering: "frequent", sunlight: "full_sun", indoor: false, edible: false }, + { id: 6, name: "Lily", watering: "average", sunlight: "part_sun", indoor: true, edible: false }, + { id: 7, name: "Rose", watering: "frequent", sunlight: "full_sun", indoor: false, edible: false }, + { id: 8, name: "Tulip", watering: "minimum", sunlight: "full_sun", indoor: false, edible: false }, + { id: 9, name: "Daisy", watering: "average", sunlight: "full_sun", indoor: false, edible: true }, + { id: 10, name: "Sunflower", watering: "average", sunlight: "full_sun", indoor: false, edible: true }, + { id: 11, name: "Lavender", watering: "minimum", sunlight: "full_sun", indoor: true, edible: true }, + { id: 12, name: "Jasmine", watering: "average", sunlight: "part_sun", indoor: true, edible: false }, + { id: 13, name: "Marigold", watering: "average", sunlight: "full_sun", indoor: false, edible: true }, + { id: 14, name: "Daffodil", watering: "average", sunlight: "part_sun", indoor: false, edible: false }, + { id: 15, name: "Carnation", watering: "average", sunlight: "full_sun", indoor: false, edible: true }, +]; + +export default function ExplorePage() { + const { t } = useTranslation(); + + const [search, setSearch] = useSessionState("explorePage.search"); + const debouncedSearch = useDebounce(search, 800); + const [openId, setOpenId] = useState(null); + const [watering, setWatering] = useSessionState("explorePage.watering"); + const [sunlight, setSunlight] = useSessionState("explorePage.sunlight"); + const [location, setLocation] = useSessionState("explorePage.location"); + const [edibility, setEdibility] = useSessionState("explorePage.edibility"); + + const filteredPlants = plants.filter( + (plant) => + plant.name.toLowerCase().includes(debouncedSearch.toLowerCase()) && + (watering === "" || plant.watering === watering) && + (sunlight === "" || plant.sunlight === sunlight) && + (location === "" || + (location === "indoor" && plant.indoor) || + (location === "outdoor" && !plant.indoor)) && + (edibility === "" || + (edibility === "edible" && plant.edible) || + (edibility === "poison" && !plant.edible)), + ); + + return ( +
    +

    + {t("explore.title", "Explore plants")} +

    + +
    e.preventDefault()} + > + + +
    + + {t("explorePage.filters", "Filters")} + + + + + + + + + +
    +
    + + + + {filteredPlants.length === 0 &&

    {t("explorePage.noResults")}

    } + +
      + {filteredPlants.map((plant) => { + const isOpen = openId === plant.id; + return ( +
    • + +
    • + ); + })} +
    +
    + ); +}