diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 303beca..851346d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,7 +14,8 @@ "react-dom": "^18.2.0", "react-icons": "^5.2.1", "react-router-dom": "^6.23.1", - "styled-components": "^6.1.11" + "styled-components": "^6.1.11", + "swr": "^2.2.5" }, "devDependencies": { "@babel/core": "^7.24.6", @@ -4851,6 +4852,11 @@ "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==", "dev": true }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -9415,6 +9421,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz", + "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==", + "dependencies": { + "client-only": "^0.0.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -9683,6 +9701,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", + "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7064f6a..c614b20 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,8 @@ "react-dom": "^18.2.0", "react-icons": "^5.2.1", "react-router-dom": "^6.23.1", - "styled-components": "^6.1.11" + "styled-components": "^6.1.11", + "swr": "^2.2.5" }, "devDependencies": { "@babel/core": "^7.24.6", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6ef698a..a6d28a2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,17 @@ -import RestaurantsPage from "./pages/RestaurantsPage/RestaurantsPage.tsx"; -import RestaurantDetailsPage from "./pages/RestaurantDetailsPage.tsx"; +import ViewRestaurantPage from "./pages/ViewRestaurantPage.tsx"; import { Route, Routes } from "react-router-dom"; -import AddRestaurantsPage from "./pages/AddRestaurantsPage.tsx"; -import RestaurantEditPage from "./pages/RestaurantEditPage.tsx"; +import CreateRestaurantPage from "./pages/CreateRestaurantPage.tsx"; +import UpdateRestaurantPage from "./pages/UpdateRestaurantPage.tsx"; import "./App.css"; +import RestaurantsPage from "./pages/RestaurantsPage.tsx"; function App() { return ( } /> - } /> - } /> - } /> + } /> + } /> + } /> ); } diff --git a/frontend/src/pages/RestaurantsPage/RestaurantsPage.styled.ts b/frontend/src/components/AlertBox/AlertBox.styled.ts similarity index 70% rename from frontend/src/pages/RestaurantsPage/RestaurantsPage.styled.ts rename to frontend/src/components/AlertBox/AlertBox.styled.ts index 592946f..355897b 100644 --- a/frontend/src/pages/RestaurantsPage/RestaurantsPage.styled.ts +++ b/frontend/src/components/AlertBox/AlertBox.styled.ts @@ -1,6 +1,6 @@ import styled from "styled-components"; -export const StyledErrorParagraph = styled.p` +export const StyledErrorContainer = styled.div` font-size: 0.8rem; color: var(--error-color); margin-top: 1rem; diff --git a/frontend/src/components/AlertBox/AlertBox.tsx b/frontend/src/components/AlertBox/AlertBox.tsx new file mode 100644 index 0000000..6b97769 --- /dev/null +++ b/frontend/src/components/AlertBox/AlertBox.tsx @@ -0,0 +1,10 @@ +import { StyledErrorContainer } from "./AlertBox.styled.ts"; +import { ReactNode } from "react"; + +type AlertBoxProps = { + children: ReactNode; +}; + +export default function AlertBox({ children }: Readonly) { + return {children}; +} diff --git a/frontend/src/components/Button/Button.component.test.tsx b/frontend/src/components/Button/Button.component.test.tsx new file mode 100644 index 0000000..caab3da --- /dev/null +++ b/frontend/src/components/Button/Button.component.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import Button from "./Button.tsx"; + +test("Button component renders a button", () => { + render( + + ); + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); +}); + +test("Button component renders children as text", () => { + render( + + ); + const text = screen.getByText(/delete/i); + expect(text).toBeInTheDocument(); +}); diff --git a/frontend/src/components/Button/Button.tsx b/frontend/src/components/Button/Button.tsx index 095b65e..6073b57 100644 --- a/frontend/src/components/Button/Button.tsx +++ b/frontend/src/components/Button/Button.tsx @@ -13,7 +13,7 @@ export default function Button({ children, handleOnClick, buttonType, -}: ButtonProps) { +}: Readonly) { return ( {children} diff --git a/frontend/src/components/ButtonLink/ButtonLink.component.test.tsx b/frontend/src/components/ButtonLink/ButtonLink.component.test.tsx new file mode 100644 index 0000000..753d209 --- /dev/null +++ b/frontend/src/components/ButtonLink/ButtonLink.component.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import ButtonLink from "./ButtonLink.tsx"; +import { MemoryRouter } from "react-router-dom"; + +test("ButtonLink component renders a link", () => { + render( + + Click here + + ); + const button = screen.getByRole("link"); + expect(button).toBeInTheDocument(); +}); + +test("ButtonLink component links to the correct target", () => { + render( + + Click here + + ); + const button = screen.getByRole("link"); + expect(button).toHaveAttribute("href", "/my-url"); +}); + +test("ButtonLink component renders children as text", () => { + render( + + Click here + + ); + const text = screen.getByText(/click here/i); + expect(text).toBeInTheDocument(); +}); diff --git a/frontend/src/components/ButtonLink/ButtonLink.tsx b/frontend/src/components/ButtonLink/ButtonLink.tsx index 583a8cf..6950d18 100644 --- a/frontend/src/components/ButtonLink/ButtonLink.tsx +++ b/frontend/src/components/ButtonLink/ButtonLink.tsx @@ -6,6 +6,9 @@ type ButtonLinkProps = { href: string; }; -export default function ButtonLink({ children, href }: ButtonLinkProps) { +export default function ButtonLink({ + children, + href, +}: Readonly) { return {children}; } diff --git a/frontend/src/components/Logo/Logo.component.test.tsx b/frontend/src/components/Logo/Logo.component.test.tsx new file mode 100644 index 0000000..12a3d2d --- /dev/null +++ b/frontend/src/components/Logo/Logo.component.test.tsx @@ -0,0 +1,9 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import Logo from "./Logo.tsx"; + +test("Button component renders a button", () => { + render(); + const button = screen.getByText(/restaurantapp/i); + expect(button).toBeInTheDocument(); +}); diff --git a/frontend/src/components/RestaurantCard/RestaurantCard.tsx b/frontend/src/components/RestaurantCard/RestaurantCard.tsx index 2ccbbdf..8980e1c 100644 --- a/frontend/src/components/RestaurantCard/RestaurantCard.tsx +++ b/frontend/src/components/RestaurantCard/RestaurantCard.tsx @@ -14,7 +14,9 @@ type RestaurantCardProps = { restaurant: RestaurantType; }; -export default function RestaurantCard({ restaurant }: RestaurantCardProps) { +export default function RestaurantCard({ + restaurant, +}: Readonly) { return ( diff --git a/frontend/src/components/RestaurantCardDetail/RestaurantCardDetail.tsx b/frontend/src/components/RestaurantCardDetail/RestaurantCardDetail.tsx index ec92a89..9f989a4 100644 --- a/frontend/src/components/RestaurantCardDetail/RestaurantCardDetail.tsx +++ b/frontend/src/components/RestaurantCardDetail/RestaurantCardDetail.tsx @@ -9,7 +9,7 @@ type RestaurantCardDetailProps = { export default function RestaurantCardDetail({ icon, value, -}: RestaurantCardDetailProps) { +}: Readonly) { return (
{icon}
diff --git a/frontend/src/components/RestaurantCardList/RestaurantCardList.tsx b/frontend/src/components/RestaurantCardList/RestaurantCardList.tsx index c91bf44..fcbe723 100644 --- a/frontend/src/components/RestaurantCardList/RestaurantCardList.tsx +++ b/frontend/src/components/RestaurantCardList/RestaurantCardList.tsx @@ -8,7 +8,7 @@ type RestaurantCardListProps = { export default function RestaurantCardList({ restaurants, -}: RestaurantCardListProps) { +}: Readonly) { return ( {restaurants.map((restaurant) => { diff --git a/frontend/src/components/RestaurantForm/RestaurantForm.tsx b/frontend/src/components/RestaurantForm/RestaurantForm.tsx index 5910298..41ccb52 100644 --- a/frontend/src/components/RestaurantForm/RestaurantForm.tsx +++ b/frontend/src/components/RestaurantForm/RestaurantForm.tsx @@ -19,7 +19,7 @@ type RestaurantFormProps = { export default function RestaurantForm({ restaurantData, onSubmit, -}: RestaurantFormProps) { +}: Readonly) { const initialFieldValidation = { title: "", city: "", @@ -62,6 +62,7 @@ export default function RestaurantForm({ onChange={handleUserInput} value={formData.title} required + autoFocus /> {fieldValidation.title} diff --git a/frontend/src/data/restaurantData.ts b/frontend/src/data/restaurantData.ts new file mode 100644 index 0000000..796b956 --- /dev/null +++ b/frontend/src/data/restaurantData.ts @@ -0,0 +1,64 @@ +import useSWR from "swr"; +import { RestaurantType } from "../model/Restaurant.ts"; +import axios from "axios"; +import { logtail } from "../logger.ts"; + +export function useRestaurants() { + const { data, error, isLoading } = useSWR( + "/api/restaurants", + (url: string) => { + logtail.info(`Trying to receive all restaurants from ${url}`); + + return axios + .get(url) + .then((response) => { + logtail.info("Received " + response.data.length + " restaurants"); + return response.data; + }) + .catch((error) => { + logtail.error(error.message, { + error: error, + }); + throw error; + }); + } + ); + + return { + restaurants: data, + isLoading, + isError: error, + }; +} + +export function useRestaurant(id: string) { + if (!id) { + throw new Error("Restaurant ID is required!"); + } + + const { data, error, isLoading } = useSWR( + `/api/restaurants/${id}`, + (url: string) => { + logtail.info(`Trying to receive restaurant from ${url}`); + + return axios + .get(url) + .then((response) => { + logtail.info(`Received restaurant with ID ${response.data.id}`); + return response.data; + }) + .catch((error) => { + logtail.error(error.message, { + error: error, + }); + throw error; + }); + } + ); + + return { + restaurant: data, + isLoading, + isError: error, + }; +} diff --git a/frontend/src/pages/AddRestaurantsPage.tsx b/frontend/src/pages/CreateRestaurantPage.tsx similarity index 90% rename from frontend/src/pages/AddRestaurantsPage.tsx rename to frontend/src/pages/CreateRestaurantPage.tsx index 2bc4bf6..4eb5d41 100644 --- a/frontend/src/pages/AddRestaurantsPage.tsx +++ b/frontend/src/pages/CreateRestaurantPage.tsx @@ -4,8 +4,9 @@ import axios from "axios"; import { useNavigate } from "react-router-dom"; import { NewRestaurantDTOType, RestaurantType } from "../model/Restaurant.ts"; import { logtail } from "../logger.ts"; +import { mutate } from "swr"; -export default function AddRestaurantsPage() { +export default function CreateRestaurantPage() { const navigate = useNavigate(); function handleAddRestaurant(formData: NewRestaurantDTOType) { @@ -16,6 +17,7 @@ export default function AddRestaurantsPage() { .then((response) => { const savedRestaurant: RestaurantType = response.data; logtail.info("Created new restaurant with ID " + savedRestaurant.id); + mutate("/api/restaurants"); navigate("/restaurants/" + savedRestaurant.id); }) .catch((error) => { diff --git a/frontend/src/pages/RestaurantDetailsPage.tsx b/frontend/src/pages/RestaurantDetailsPage.tsx deleted file mode 100644 index 45eb672..0000000 --- a/frontend/src/pages/RestaurantDetailsPage.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import DefaultPageTemplate from "./templates/DefaultPageTemplate/DefaultPageTemplate.tsx"; -import axios from "axios"; -import { RestaurantType } from "../model/Restaurant.ts"; - -import Button from "../components/Button/Button.tsx"; -import ButtonLink from "../components/ButtonLink/ButtonLink.tsx"; -import { logtail } from "../logger.ts"; - -export default function RestaurantDetailsPage() { - const navigate = useNavigate(); - const { id } = useParams<{ id: string }>(); - const [restaurant, setRestaurant] = useState(); - const [error, setError] = useState(null); - - useEffect(() => { - logtail.info("Trying to receive data for restaurant with ID " + id); - - axios - .get(`/api/restaurants/${id}`) - .then((response) => { - logtail.info("Received data of restaurant with ID " + id); - setRestaurant(response.data); - }) - .catch((error) => { - logtail.error(error.message, { - error: error, - }); - setError("There was an error fetching the restaurant details!"); - console.error(error); - }); - }, [id]); - - if (error) { - return {error}; - } - - if (!restaurant) { - return Loading...; - } - - function deleteRestaurantById() { - axios.delete(`/api/restaurants/${id}`).then(() => navigate("/")); - } - - return ( - -

{restaurant.city}

- Edit - Back - -
- ); -} diff --git a/frontend/src/pages/RestaurantEditPage.tsx b/frontend/src/pages/RestaurantEditPage.tsx deleted file mode 100644 index 3eeadb9..0000000 --- a/frontend/src/pages/RestaurantEditPage.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useNavigate, useParams } from "react-router-dom"; -import { useEffect, useState } from "react"; -import { NewRestaurantDTOType, RestaurantType } from "../model/Restaurant.ts"; -import axios from "axios"; -import DefaultPageTemplate from "./templates/DefaultPageTemplate/DefaultPageTemplate.tsx"; -import RestaurantForm from "../components/RestaurantForm/RestaurantForm.tsx"; -import { logtail } from "../logger.ts"; - -export default function RestaurantEditPage() { - const navigate = useNavigate(); - const { id } = useParams<{ id: string }>(); - const [restaurant, setRestaurant] = useState(); - const [error, setError] = useState(null); - - useEffect(() => { - logtail.info("Trying to receive data for restaurant with ID " + id); - - axios - .get(`/api/restaurants/${id}`) - .then((response) => { - logtail.info("Received data of restaurant with ID " + id); - setRestaurant(response.data); - }) - .catch((error) => { - logtail.error(error.message, { - error: error, - }); - setError("There was an error fetching the restaurant details!"); - console.error(error); - }); - }, [id]); - - if (error) { - return {error}; - } - - if (!restaurant) { - return Loading...; - } - - function handleEditRestaurant(formData: NewRestaurantDTOType) { - logtail.info("Trying to update data for restaurant with ID " + id); - - axios - .put(`/api/restaurants/${id}`, formData) - .then(() => { - logtail.info("Updated data of restaurant with ID " + id); - navigate("/"); - }) - .catch((error) => { - logtail.error(error.message, { - error: error, - }); - window.console.error(error.message); - }); - } - - return ( - - - - ); -} diff --git a/frontend/src/pages/RestaurantsPage.tsx b/frontend/src/pages/RestaurantsPage.tsx new file mode 100644 index 0000000..5a34da8 --- /dev/null +++ b/frontend/src/pages/RestaurantsPage.tsx @@ -0,0 +1,41 @@ +import { useRestaurants } from "../data/restaurantData.ts"; +import DefaultPageTemplate from "./templates/DefaultPageTemplate/DefaultPageTemplate.tsx"; +import AlertBox from "../components/AlertBox/AlertBox.tsx"; +import CreateDataInvitation from "../components/CreateDataInvitation/CreateDataInvitation.tsx"; +import RestaurantCardList from "../components/RestaurantCardList/RestaurantCardList.tsx"; + +export default function RestaurantsPage() { + const { restaurants, isLoading, isError } = useRestaurants(); + + if (isLoading) { + return ( + +

Restaurants are currently loading. Please wait.

+
+ ); + } + + if (isError) { + return ( + +

+ Sorry, we encountered an error loading the restaurants. Please try + again later. +

+ {isError.message} +
+ ); + } + + if (restaurants) { + return ( + + {restaurants.length === 0 ? ( + + ) : ( + + )} + + ); + } +} diff --git a/frontend/src/pages/RestaurantsPage/RestaurantsPage.tsx b/frontend/src/pages/RestaurantsPage/RestaurantsPage.tsx deleted file mode 100644 index acca783..0000000 --- a/frontend/src/pages/RestaurantsPage/RestaurantsPage.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import RestaurantCardList from "../../components/RestaurantCardList/RestaurantCardList.tsx"; -import DefaultPageTemplate from "../templates/DefaultPageTemplate/DefaultPageTemplate.tsx"; -import axios, { AxiosError } from "axios"; -import { useEffect, useState } from "react"; -import { RestaurantType } from "../../model/Restaurant.ts"; -import CreateDataInvitation from "../../components/CreateDataInvitation/CreateDataInvitation.tsx"; -import { StyledErrorParagraph } from "./RestaurantsPage.styled.ts"; -import { logtail } from "../../logger.ts"; - -export default function RestaurantsPage() { - const [restaurants, setRestaurants] = useState([]); - const [error, setError] = useState(); - - useEffect(() => { - logtail.info("Trying to receive all restaurants from /api/restaurants"); - - axios - .get("/api/restaurants") - .then((response) => { - logtail.info("Received " + response.data.length + " restaurants"); - setRestaurants(response.data); - }) - .catch((error) => { - logtail.error(error.message, { - error: error, - }); - setError(error); - console.error(error.message); - }); - }, []); - - if (error) { - return ( - -

- Sorry, we encountered an error loading the restaurants. Please try - again later. -

- {error.message} -
- ); - } - - return ( - - {restaurants.length === 0 ? ( - - ) : ( - - )} - - ); -} diff --git a/frontend/src/pages/UpdateRestaurantPage.tsx b/frontend/src/pages/UpdateRestaurantPage.tsx new file mode 100644 index 0000000..738ffdf --- /dev/null +++ b/frontend/src/pages/UpdateRestaurantPage.tsx @@ -0,0 +1,69 @@ +import { useNavigate, useParams } from "react-router-dom"; +import { NewRestaurantDTOType } from "../model/Restaurant.ts"; +import axios from "axios"; +import DefaultPageTemplate from "./templates/DefaultPageTemplate/DefaultPageTemplate.tsx"; +import RestaurantForm from "../components/RestaurantForm/RestaurantForm.tsx"; +import { logtail } from "../logger.ts"; +import { useRestaurant } from "../data/restaurantData.ts"; +import AlertBox from "../components/AlertBox/AlertBox.tsx"; +import { mutate } from "swr"; + +export default function UpdateRestaurantPage() { + const navigate = useNavigate(); + const { id: paramId } = useParams<{ id: string }>(); + const id = paramId ?? ""; + const { restaurant, isLoading, isError } = useRestaurant(id); + + if (isLoading) { + return ( + +

Restaurant is currently loading. Please wait.

+
+ ); + } + + if (isError) { + return ( + +

+ Sorry, we encountered an error loading the restaurants. Please try + again later. +

+ {isError.message} +
+ ); + } + + if (!restaurant) { + logtail.error(`There was an error displaying restaurant with ID ${id}`); + return Error; + } + + function handleEditRestaurant(formData: NewRestaurantDTOType) { + logtail.info("Trying to update data for restaurant with ID " + id); + + axios + .put(`/api/restaurants/${id}`, formData) + .then(() => { + logtail.info(`Updated data of restaurant with ID ${id}`); + mutate(`/api/restaurants/${id}`); + mutate("/api/restaurants"); + navigate(`/restaurants/${id}`); + }) + .catch((error) => { + logtail.error(error.message, { + error: error, + }); + window.console.error(error.message); + }); + } + + return ( + + + + ); +} diff --git a/frontend/src/pages/ViewRestaurantPage.tsx b/frontend/src/pages/ViewRestaurantPage.tsx new file mode 100644 index 0000000..040fe1a --- /dev/null +++ b/frontend/src/pages/ViewRestaurantPage.tsx @@ -0,0 +1,60 @@ +import { useNavigate, useParams } from "react-router-dom"; +import DefaultPageTemplate from "./templates/DefaultPageTemplate/DefaultPageTemplate.tsx"; +import axios from "axios"; + +import Button from "../components/Button/Button.tsx"; +import ButtonLink from "../components/ButtonLink/ButtonLink.tsx"; +import { useRestaurant } from "../data/restaurantData.ts"; +import { logtail } from "../logger.ts"; +import AlertBox from "../components/AlertBox/AlertBox.tsx"; +import { mutate } from "swr"; + +export default function ViewRestaurantPage() { + const navigate = useNavigate(); + const { id: paramId } = useParams<{ id: string }>(); + const id = paramId ?? ""; + const { restaurant, isLoading, isError } = useRestaurant(id); + + if (isLoading) { + return ( + +

Restaurant is currently loading. Please wait.

+
+ ); + } + + if (isError) { + return ( + +

+ Sorry, we encountered an error loading the restaurants. Please try + again later. +

+ {isError.message} +
+ ); + } + + if (!restaurant) { + logtail.error(`There was an error displaying restaurant with ID ${id}`); + return Error; + } + + function deleteRestaurantById() { + axios.delete(`/api/restaurants/${id}`).then(() => { + mutate("/api/restaurants"); + navigate("/"); + }); + } + + return ( + +

{restaurant.city}

+ Edit + Back + +
+ ); +} diff --git a/frontend/src/pages/templates/DefaultPageTemplate/DefaultPageTemplate.tsx b/frontend/src/pages/templates/DefaultPageTemplate/DefaultPageTemplate.tsx index 37954ba..b61e6b0 100644 --- a/frontend/src/pages/templates/DefaultPageTemplate/DefaultPageTemplate.tsx +++ b/frontend/src/pages/templates/DefaultPageTemplate/DefaultPageTemplate.tsx @@ -10,7 +10,7 @@ type DefaultPageTemplateProps = { export default function DefaultPageTemplate({ children, pageTitle, -}: DefaultPageTemplateProps) { +}: Readonly) { return ( <>