diff --git a/src/app.tsx b/src/app.tsx index 4cfddb3..3309274 100755 --- a/src/app.tsx +++ b/src/app.tsx @@ -10,6 +10,7 @@ import { PersistGate } from 'redux-persist/integration/react' import { AdminProtectedRoute, UserProtectedRoute } from './routes/ProtectedRoute' import { getRoutes } from './routes'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import ScrollToTop from './components/ScrollToTop' const queryClient = new QueryClient({ defaultOptions: { @@ -28,6 +29,7 @@ export function App() { + } /> }> diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index 03ad0f9..a3f99cc 100755 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -9,6 +9,7 @@ import { getSearchLocationService } from "../../services/locations"; import { Link, useNavigate } from "react-router-dom"; import { ReactSelectData } from "src/types/common"; import { useDispatch, useSelector } from "react-redux"; +import { DEFAULT_AVATAR_IMG } from "../../constants/default"; function Header() { @@ -114,16 +115,17 @@ function Header() { user.username ? setPageState({ ...pageState, profileMenu: !pageState.profileMenu }) : ''}> {user.username &&
-
Profile
-
Feed
-
Add location
-
Settings
+ setPageState({ ...pageState, profileMenu: false })} to={'/user/profile'}>
Profile
+ setPageState({ ...pageState, profileMenu: false })} to={'#'}>
Feed
+ setPageState({ ...pageState, profileMenu: false })} to={'/add-location'}>
Add location
+ setPageState({ ...pageState, profileMenu: false })} to={'/user/setting'}>
Settings
Logout
{/* */} {/* */} @@ -187,23 +189,23 @@ function Header() { {dropdown && Home } - Top Places - Discover - Stories - News / Events + setDropdown(!dropdown)} className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Top Places + setDropdown(!dropdown)} className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Discover + setDropdown(!dropdown)} className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Stories + setDropdown(!dropdown)} className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>News / Events Forum
user.username ? setPageState({ ...pageState, profileMenu: !pageState.profileMenu }) : ''} className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>{user.username ? user.username : 'Sign in'} {user && screen.width > 600 &&
setPageState({ ...pageState, profileMenu: !pageState.profileMenu })}>
Add location
-
Profile
-
Feed
-
Settings
+ setPageState({ ...pageState, profileMenu: !pageState.profileMenu })}>
Profile
+ setPageState({ ...pageState, profileMenu: !pageState.profileMenu })}>
Feed
+ setPageState({ ...pageState, profileMenu: !pageState.profileMenu })}>
Settings
{user.is_admin && -
Submissions
+ setPageState({ ...pageState, profileMenu: !pageState.profileMenu })}>
Submissions
} -
Logout
+
Logout
{/* */} {/* */}
diff --git a/src/components/Icons/CleanlinessIcon/index.tsx b/src/components/Icons/CleanlinessIcon/index.tsx new file mode 100644 index 0000000..38c8788 --- /dev/null +++ b/src/components/Icons/CleanlinessIcon/index.tsx @@ -0,0 +1,39 @@ +interface CleanlinessIconProps { + width?: number; + height?: number; + fill?: string; + className?: string; + bold?: boolean; + strokeWidth?: number; +} + +export function CleanlinessIcon({ width = 30, height = 30, fill = 'white', className, bold = false, strokeWidth = 0.6 }: CleanlinessIconProps) { + const stroke = bold ? fill : 'none'; + const sw = bold ? strokeWidth : 0; + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/Icons/FacilityIcon/index.tsx b/src/components/Icons/FacilityIcon/index.tsx new file mode 100644 index 0000000..3f3f7db --- /dev/null +++ b/src/components/Icons/FacilityIcon/index.tsx @@ -0,0 +1,27 @@ +interface FacilityIconProps { + width?: number; + height?: number; + fill?: string; + className?: string; + bold?: boolean; + strokeWidth?: number; +} + +export function FacilityIcon({ width = 30, height = 30, fill = 'white', className, bold = false, strokeWidth = 0.6 }: FacilityIconProps) { + const stroke = bold ? fill : 'none'; + const sw = bold ? strokeWidth : 0; + return ( + + + + ); +} diff --git a/src/components/Icons/MenuIcon/index.tsx b/src/components/Icons/MenuIcon/index.tsx new file mode 100644 index 0000000..1d2f781 --- /dev/null +++ b/src/components/Icons/MenuIcon/index.tsx @@ -0,0 +1,31 @@ +interface MenuIconProps { + width?: number; + height?: number; + fill?: string; + className?: string; + bold?: boolean; + strokeWidth?: number; +} + +export function MenuIcon({ width = 30, height = 30, fill = 'white', className, bold = false, strokeWidth = 0.6 }: MenuIconProps) { + const stroke = bold ? fill : 'none'; + const sw = bold ? strokeWidth : 0; + return ( + + + + + + + + ); +} diff --git a/src/components/Icons/RestaurantIcon/index.tsx b/src/components/Icons/RestaurantIcon/index.tsx new file mode 100644 index 0000000..d89dff0 --- /dev/null +++ b/src/components/Icons/RestaurantIcon/index.tsx @@ -0,0 +1,31 @@ +interface RestaurantIconProps { + width?: number; + height?: number; + fill?: string; + className?: string; + bold?: boolean; + strokeWidth?: number; +} + +export function RestaurantIcon({ width = 30, height = 30, fill = 'white', className, bold = false, strokeWidth = 0 }: RestaurantIconProps) { + const stroke = bold ? fill : 'none'; + const sw = bold ? strokeWidth : 0; + return ( + + + + + + + + ); +} diff --git a/src/components/Icons/ServiceIcon/index.tsx b/src/components/Icons/ServiceIcon/index.tsx new file mode 100644 index 0000000..6ab563d --- /dev/null +++ b/src/components/Icons/ServiceIcon/index.tsx @@ -0,0 +1,38 @@ +interface ServiceIconProps { + width?: number; + height?: number; + fill?: string; + className?: string; + bold?: boolean; + strokeWidth?: number; +} + +export function ServiceIcon({ width = 30, height = 30, fill = 'white', className, bold = false, strokeWidth = 0.6 }: ServiceIconProps) { + const stroke = bold ? fill : 'none'; + const sw = bold ? strokeWidth : 0; + return ( + + + + + + + + + + + + + + + ); +} diff --git a/src/components/ScrollToTop/index.tsx b/src/components/ScrollToTop/index.tsx new file mode 100644 index 0000000..9165f1e --- /dev/null +++ b/src/components/ScrollToTop/index.tsx @@ -0,0 +1,10 @@ +import { useEffect } from 'preact/hooks' +import { useLocation } from 'react-router-dom' + +export default function ScrollToTop() { + const { pathname } = useLocation() + useEffect(() => { + window.scrollTo(0, 0) + }, [pathname]) + return null +} diff --git a/src/constants/api.ts b/src/constants/api.ts index ce55a50..dbf0e11 100755 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -27,6 +27,7 @@ const POST_CREATE_LOCATION = GET_LIST_LOCATIONS_URI; const GET_IMAGES_BY_LOCATION_URI = `${BASE_URL}/images/location`; const POST_REVIEW_LOCATION_URI = `${BASE_URL}/review/location`; +const POST_REVIEW_IMAGES_URI = `${BASE_URL}/review/location/images`; const GET_CURRENT_USER_REVIEW_LOCATION_URI = `${BASE_URL}/user/review/location`; export { @@ -51,6 +52,7 @@ export { PATCH_USER_AVATAR, PATCH_USER_INFO, POST_REVIEW_LOCATION_URI, + POST_REVIEW_IMAGES_URI, POST_CREATE_LOCATION, POST_NEWS_EVENTS_URI, } \ No newline at end of file diff --git a/src/constants/default.ts b/src/constants/default.ts index 3d485ee..b7792b6 100755 --- a/src/constants/default.ts +++ b/src/constants/default.ts @@ -1,3 +1,3 @@ -export const DEFAULT_AVATAR_IMG = 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'; +export const DEFAULT_AVATAR_IMG = 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQPdKbLIfst1MqPxPK2lZZ7odhmD1P1aU3ReQ&s'; export const DEFAULT_LOCATION_THUMBNAIL_IMG = 'https://i.ytimg.com/vi/0DY1WSk8B9o/maxresdefault.jpg'; diff --git a/src/domains/LocationInfo.ts b/src/domains/LocationInfo.ts index 07630cc..01b5b67 100755 --- a/src/domains/LocationInfo.ts +++ b/src/domains/LocationInfo.ts @@ -1,9 +1,10 @@ export type LocationInfo = { id: number, name: string, - thumbnail: string | null, - regency_name: String, - province_name: String, + thumbnail: string, + regency_name: string, + province_name: string, + location_type: string, critic_score: number, critic_count: number, user_score: number, diff --git a/src/pages/AddLocation/index.tsx b/src/pages/AddLocation/index.tsx index a31418a..3196363 100755 --- a/src/pages/AddLocation/index.tsx +++ b/src/pages/AddLocation/index.tsx @@ -21,7 +21,7 @@ function AddLocation() { name: '', address: '', google_maps_link: '', - location_type: LocationType.Beach, + location_type: LocationType.Recreation, regency: emptyRegency(), thumbnails: [], }) @@ -103,7 +103,7 @@ function AddLocation() { const files = Array.from(event.files as ArrayLike); const result = files.filter((x) => { - if (x.type === "image/jpg" || x.type === "image/png" || x.type === "image/jpeg") { + if (x.type === "image/jpg" || x.type === "image/png" || x.type === "image/jpeg" || x.type == "image/webp") { return true } return false diff --git a/src/pages/BestLocations/index.tsx b/src/pages/BestLocations/index.tsx index c9df0dd..c150d5e 100755 --- a/src/pages/BestLocations/index.tsx +++ b/src/pages/BestLocations/index.tsx @@ -3,24 +3,25 @@ import { useEffect, useState } from "preact/hooks"; import { getListTopLocationsService } from "../../services"; import { DefaultSeparator } from "../../components"; import './style.css'; -import { useClick, useFloating, useInteractions } from "@floating-ui/react"; +import { UserStar, User, Map, HandPlatter, ClockCheck } from 'lucide-react'; import { Link } from "react-router-dom"; interface TopLocation { - row_number: Number, - id: Number, - name: String, - thumbnail: string | null, - address: String, + row_number: number, + id: number, + name: string, + location_type: string, + thumbnail: string, + address: string, google_maps_link: string, regency_name: string, - critic_score: Number, - critic_count: Number, - user_score: Number, - user_count: Number, - critic_bayes: Number, - user_bayes: Number, - avg_bayes: Number, + critic_score: number, + critic_count: number, + user_score: number, + user_count: number, + critic_bayes: number, + user_bayes: number, + avg_bayes: number, } const REGIONS = [ @@ -47,8 +48,14 @@ const MIN_REVIEWS = [ 11 ] +function getRatingColorClass(score: number): string { + if (score < 40) return 'text-rating-red'; + if (score < 70) return 'text-rating-yellow'; + return 'text-rating-green'; +} + function BestLocation() { - const [page, _setPage] = useState(1); +const [page, _setPage] = useState(1); const [topLocations, setTopLocations] = useState>([]) const [pageState, setPageState] = useState({ filterScoreType: 'all', @@ -132,7 +139,7 @@ function BestLocation() {
{topLocations.map(x => ( - <> +
{/* UNCOMMENT....IF THERES A PURPOSE FOR RIGHT THREE DOTS */} {/* */}
- {x.row_number}.{x.name} + {x.row_number}. {x.name}, {x.regency_name}
-
- - +
+ + { + e.currentTarget.src = x.location_type === 'culinary' ? 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/restaorunta.webp' : 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp'; + e.currentTarget.onerror = null; + }} + /> + {/*
+ {x.location_type === 'culinary' && + Menu + } + 08.00 - 22.00 +
*/}
-
{x.regency_name}
-
{x.address}
-
$$$ (IDR 1000-12000)
-
Maps Location
-
-
-
CRITICS SCORE
-
-
-

{x.critic_score !== 0 ? Number(x.critic_score) / Number(x.critic_count) * 10 : "N/A"}

-
-
-
+ {/*
{x.regency_name}
*/} + +
+ {x.location_type === 'culinary' && + Menu + } + Open +
+ {/* + */} + {/*
$$$ (IDR 1000-12000)
*/} + {/*
Maps Location
*/} +
+
+
+
+ +
+
+
Critic Score
+
+ {x.critic_score !== 0 ? Math.round(Number(x.critic_score) / Number(x.critic_count) * 10) : "N/A"} +
+
{x.critic_count} reviews
-

{x.critic_count} reviews

-
-
USERS SCORE
-
-
-

{x.user_score !== 0 ? x.user_score : "N/A"}

-
-
-
+
+
+
+ +
+
+
User Score
+
+ {x.user_score !== 0 ? Math.round(x.user_score) : "N/A"} +
+
{x.user_count} reviews
-

{x.user_count} reviews

- +
))}
-
+ {/*
{REVIEWERS_TYPE.map((x, idx) => ( ))}
-
+
*/}
diff --git a/src/pages/Discovery/index.tsx b/src/pages/Discovery/index.tsx index 2094869..48de8fa 100755 --- a/src/pages/Discovery/index.tsx +++ b/src/pages/Discovery/index.tsx @@ -1,160 +1,21 @@ -import { useEffect, useState } from "preact/hooks"; import { CheckboxInput, DefaultSeparator, FilterButton, LocationCard, SpinnerLoading } from "../../components"; -import { getListRecentLocationsRatingsService, getRegenciesService, } from "../../services"; -import { LocationInfo, Regency } from "../../domains"; -import { LocationType } from "../../types/common"; -import { enumKeys } from "../../utils"; -import { useNavigate } from "react-router-dom"; import { FloatFilter } from "../../components/Filter/FloatFilter"; +import { useDiscovery } from "./useDiscovery"; function Discovery() { - - interface DiscoveryState { - filterQ: string, - regencies: any[], - searchRegencies: any[], - locations: LocationInfo[], - locationType: Array<{value: string, isSelected?: boolean}> - } - - const [data, setData] = useState({ - filterQ: '', - regencies: [], - searchRegencies: [], - locations: [], - locationType: [] - }); - const [isFloatFilterOpen, setFloatFilterOpen] = useState(false) - const [isLoading, setIsLoading] = useState(true) - const navigate = useNavigate() - - useEffect(() => { - loadData() - }, []) - - async function loadData() { - setIsLoading(true) - try { - await Promise.all([ - getLocationType(), - getRecentLocations(), - getRegion() - ]) - } finally { - setIsLoading(false) - } - } - - async function getRegion() { - try { - const res = await getRegenciesService(); - setData((prevState) => ({ ...prevState, regencies: res.data, searchRegencies: res.data })) - } catch (err) { - alert("Something went wrong when fetch regions / regencies") - } - } - - async function getRecentLocations() { - try { - const locations = await getListRecentLocationsRatingsService(15, 1) - setData((prevState) => ({ ...prevState, locations: locations.data })) - } catch (error) { - console.log(error) - } - } - - function onCheckedRegencyFilter(_val: any, id: number) { - // const value = val as HTMLInputElement; - const dataRegencies = data.searchRegencies as (Regency & { isSelected?: boolean })[]; - const regencyIdx = dataRegencies.findIndex(x => x.id == id); - dataRegencies[regencyIdx].isSelected = !dataRegencies[regencyIdx].isSelected - setData({ - ...data, - regencies: dataRegencies, - searchRegencies: dataRegencies - }) - } - - function onClearFilter() { - const dataRegencies = data.searchRegencies as (Regency & { isSelected?: boolean })[]; - const regencies = dataRegencies.map(x => ({ ...x, isSelected: false })) - - setData({ - ...data, - regencies: regencies, - searchRegencies: regencies - }) - } - - - function onInputChange(search: string) { - const dataRegencies = data.regencies as (Regency & { isSelected?: boolean })[]; - // console.log(dataRegencies.filter(x => x.regency_name.toLowerCase().includes(search))) - setData({ - ...data, - searchRegencies: dataRegencies.filter(x => x.regency_name.toLowerCase().includes(search)) - }) - } - - - function onClickedTypeLocations(idx: number) { - const locType = data.locationType - locType[idx].isSelected = !locType[idx].isSelected - setData({ - ...data, - locationType: locType - }) - } - - function onApplyFilter() { - const dataRegencies = data.regencies as (Regency & { isSelected?: boolean})[]; - const selectedRegencies = dataRegencies.filter(x => x.isSelected) - const selectedLocType = data.locationType.filter(x => x.isSelected) - let regenciesQ = ""; - let locTypeQ = "" - - if(selectedRegencies.length > 0) { - regenciesQ = selectedRegencies.map(x => x.id).join(" OR ").replace(/\s+/g, '%20') - - } - if(selectedLocType.length > 0) { - const onJoin = selectedRegencies.length > 0 ? " AND " : " OR " - locTypeQ = selectedLocType.map(x => x.value).join(onJoin).replace(/\s+/g, '%20') - - if(selectedRegencies.length > 0) locTypeQ = `%20AND%20${locTypeQ}` - } - - const searchQ = `${regenciesQ}${locTypeQ}` - - - - - } - - function getLocationType() { - const type: Array<{value: string, isSelected?: boolean}> = [] - for (const lt of enumKeys(LocationType)) { - type.push({value: LocationType[lt]}); - } - - setData((prevState) => ({ ...prevState, locationType: type })) - - } - - function onClickLocation(id: number) { - navigate(`/location/${id}`) - } - - function onDeleteByCityFilter(value: any) { - const dataRegencies = data.searchRegencies as (Regency & { isSelected?: boolean })[]; - const regencyIdx = dataRegencies.findIndex(x => x.id == value.id); - dataRegencies[regencyIdx].isSelected = !dataRegencies[regencyIdx].isSelected - setData({ - ...data, - regencies: dataRegencies, - searchRegencies: dataRegencies - }) - } + const { + data, + isFloatFilterOpen, + setFloatFilterOpen, + isLoading, + onCheckedRegencyFilter, + onClearFilter, + onInputChange, + onClickedTypeLocations, + onApplyFilter, + onClickLocation, + onDeleteByCityFilter + } = useDiscovery(); if (isLoading) { return ( @@ -228,7 +89,7 @@ function Discovery() { {/* FILTER END */}
- {data.locations.map(x => ( + {data.locations.map((x: any) => ( ; +} + +export function useDiscovery() { + const [data, setData] = useState({ + filterQ: '', + regencies: [], + searchRegencies: [], + locationType: [] + }); + const [isFloatFilterOpen, setFloatFilterOpen] = useState(false); + const navigate = useNavigate(); + + const { data: locationsData, isLoading: isLoadingLocations, error: locationsError } = useRecentLocationsRatings(15, 1); + const { data: regenciesData, isLoading: isLoadingRegencies, error: regenciesError } = useRegencies(); + + const isLoading = isLoadingLocations || isLoadingRegencies; + + useEffect(() => { + if (regenciesData) { + const regenciesWithSelection = regenciesData.map((r: Regency) => ({ ...r, isSelected: false })); + setData((prevState) => ({ + ...prevState, + regencies: regenciesWithSelection, + searchRegencies: regenciesWithSelection + })); + } + }, [regenciesData]); + + useEffect(() => { + const type: Array<{ value: string; isSelected?: boolean }> = []; + for (const lt of enumKeys(LocationType)) { + type.push({ value: LocationType[lt], isSelected: false }); + } + setData((prevState) => ({ ...prevState, locationType: type })); + }, []); + + useEffect(() => { + if (regenciesError) { + alert("Something went wrong when fetching regions / regencies"); + } + }, [regenciesError]); + + useEffect(() => { + if (locationsError) { + console.error("Error fetching locations:", locationsError); + } + }, [locationsError]); + + function onCheckedRegencyFilter(_val: any, id: number) { + const dataRegencies = [...data.searchRegencies]; + const regencyIdx = dataRegencies.findIndex(x => x.id == id); + if (regencyIdx !== -1) { + dataRegencies[regencyIdx] = { + ...dataRegencies[regencyIdx], + isSelected: !dataRegencies[regencyIdx].isSelected + }; + setData({ + ...data, + regencies: dataRegencies, + searchRegencies: dataRegencies + }); + } + } + + function onClearFilter() { + const regencies = data.searchRegencies.map(x => ({ ...x, isSelected: false })); + setData({ + ...data, + regencies: regencies, + searchRegencies: regencies + }); + } + + function onInputChange(search: string) { + const searchLower = search.toLowerCase(); + setData({ + ...data, + searchRegencies: data.regencies.filter(x => x.regency_name.toLowerCase().includes(searchLower)) + }); + } + + function onClickedTypeLocations(idx: number) { + const locType = [...data.locationType]; + if (idx >= 0 && idx < locType.length) { + locType[idx] = { + ...locType[idx], + isSelected: !locType[idx].isSelected + }; + setData({ + ...data, + locationType: locType + }); + } + } + + function onApplyFilter() { + const selectedRegencies = data.regencies.filter(x => x.isSelected); + const selectedLocType = data.locationType.filter(x => x.isSelected); + let regenciesQ = ""; + let locTypeQ = ""; + + if (selectedRegencies.length > 0) { + regenciesQ = selectedRegencies.map(x => x.id).join(" OR ").replace(/\s+/g, '%20'); + } + if (selectedLocType.length > 0) { + const onJoin = selectedRegencies.length > 0 ? " AND " : " OR "; + locTypeQ = selectedLocType.map(x => x.value).join(onJoin).replace(/\s+/g, '%20'); + + if (selectedRegencies.length > 0) locTypeQ = `%20AND%20${locTypeQ}`; + } + + const searchQ = `${regenciesQ}${locTypeQ}`; + // TODO: Implement search with searchQ + console.log("Search query:", searchQ); + } + + function onClickLocation(id: number) { + navigate(`/location/${id}`); + } + + function onDeleteByCityFilter(value: any) { + const dataRegencies = [...data.searchRegencies]; + const regencyIdx = dataRegencies.findIndex(x => x.id == value.id); + if (regencyIdx !== -1) { + dataRegencies[regencyIdx] = { + ...dataRegencies[regencyIdx], + isSelected: !dataRegencies[regencyIdx].isSelected + }; + setData({ + ...data, + regencies: dataRegencies, + searchRegencies: dataRegencies + }); + } + } + + return { + data: { + ...data, + locations: locationsData || [] + }, + isFloatFilterOpen, + setFloatFilterOpen, + isLoading, + onCheckedRegencyFilter, + onClearFilter, + onInputChange, + onClickedTypeLocations, + onApplyFilter, + onClickLocation, + onDeleteByCityFilter + }; +} diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index df04ac1..3412488 100755 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -1,67 +1,16 @@ import { LocationCard, SeparatorWithAnchor } from '../../components'; -// import news from '../../datas/recent_news_event.json'; import popular from '../../datas/popular.json'; -// import popular_user_review from '../../datas/popular_user_reviews.json'; import './style.css'; -import { useEffect, useState } from 'preact/hooks'; -import { getListRecentLocationsRatingsService, getListTopLocationsService } from '../../services'; -import { useNavigate } from 'react-router-dom'; -import { LocationInfo } from '../../domains/LocationInfo'; - -type News = { - header: string, - thumbnail: string, - link: string, - comments_count: Number - likes_count: Number -} +import { useHome } from './useHome'; +import { User } from 'lucide-react'; function Home() { - const [recentLocations, setRecentLocations] = useState>([]) - const [topCriticsLocations, setTopCriticsLocations] = useState>([]) - const [topUsersLocations, setTopUsersLocations] = useState>([]) - // const [isLoading, setIsLoading] = useState(true) - - const navigate = useNavigate() - - - async function getRecentLocations() { - try { - const locations = await getListRecentLocationsRatingsService(12, 1) - setRecentLocations(locations.data) - // setIsLoading(false) - } catch (error) { - console.log(error) - } - } - - async function getCrititsBestLocations() { - try { - const res = await getListTopLocationsService({ page: 1, page_size: 6, order_by: 2, region_type: 0 }) - setTopCriticsLocations(res.data) - } catch (err) { - console.log(err) - } - } - - async function getUsersBestLocations() { - try { - const res = await getListTopLocationsService({ page: 1, page_size: 6, order_by: 3, region_type: 0 }) - setTopUsersLocations(res.data) - } catch (err) { - console.log(err) - } - } - - function onNavigateToDetail(id: Number,) { - navigate(`/location/${id}`) - } - - useEffect(() => { - getRecentLocations() - getCrititsBestLocations() - getUsersBestLocations() - }, []) + const { + recentLocations, + topCriticsLocations, + topUsersLocations, + onNavigateToDetail, + } = useHome(); return (
@@ -173,7 +122,7 @@ function Home() {
- +
{popular.data.map((x) => (
@@ -190,6 +139,7 @@ function Home() {

{x.name}

{x.location}

+

1.2k users visit this month

@@ -198,23 +148,30 @@ function Home() {
- + {topCriticsLocations.map((x) => (
{ + e.currentTarget.src = x.location_type === 'culinary' ? 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/restaorunta.webp' : 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp'; + e.currentTarget.onerror = null; + }} />

{x.name}

{x.regency_name}

-
-

{x.critic_score} ({x.critic_count})

-
-
+
+
+

{x.critic_score}

+
+
+
+ ({x.critic_count} )
@@ -222,22 +179,30 @@ function Home() { }
- + {topUsersLocations.map((x) => (
{ + e.currentTarget.src = x.location_type === 'culinary' ? 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/restaorunta.webp' : 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp'; + e.currentTarget.onerror = null; + }} />

{x.name}

{x.regency_name}

-
-

{x.user_score} ({x.user_count})

-
-
+
+
+

{x.user_score}

+
+
+
+ ({x.user_count} )
diff --git a/src/pages/Home/useHome.tsx b/src/pages/Home/useHome.tsx new file mode 100644 index 0000000..90f3f24 --- /dev/null +++ b/src/pages/Home/useHome.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'preact/hooks'; +import { useNavigate } from 'react-router-dom'; +import { getListRecentLocationsRatingsService, getListTopLocationsService } from '../../services'; +import { LocationInfo } from '../../domains/LocationInfo'; + +export const useHome = () => { + const [recentLocations, setRecentLocations] = useState>([]); + const [topCriticsLocations, setTopCriticsLocations] = useState>([]); + const [topUsersLocations, setTopUsersLocations] = useState>([]); + // const [isLoading, setIsLoading] = useState(true) + + const navigate = useNavigate(); + + async function getRecentLocations() { + try { + const locations = await getListRecentLocationsRatingsService(12, 1); + setRecentLocations(locations.data); + // setIsLoading(false) + } catch (error) { + console.log(error); + } + } + + async function getCrititsBestLocations() { + try { + const res = await getListTopLocationsService({ page: 1, page_size: 6, order_by: 2, region_type: 0 }); + setTopCriticsLocations(res.data); + } catch (err) { + console.log(err); + } + } + + async function getUsersBestLocations() { + try { + const res = await getListTopLocationsService({ page: 1, page_size: 6, order_by: 3, region_type: 0 }); + setTopUsersLocations(res.data); + } catch (err) { + console.log(err); + } + } + + function onNavigateToDetail(id: Number) { + navigate(`/location/${id}`); + } + + useEffect(() => { + getRecentLocations(); + getCrititsBestLocations(); + getUsersBestLocations(); + }, []); + + return { + recentLocations, + topCriticsLocations, + topUsersLocations, + onNavigateToDetail, + }; +}; diff --git a/src/pages/LocationDetail/index.tsx b/src/pages/LocationDetail/index.tsx index 98c23d8..3bec69d 100755 --- a/src/pages/LocationDetail/index.tsx +++ b/src/pages/LocationDetail/index.tsx @@ -10,17 +10,20 @@ import { emptyLocationResponse, CurrentUserLocationReviews, } from './types'; -import { AxiosError } from 'axios'; -import { handleAxiosError, useAutosizeTextArea } from '../../utils'; -import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation } from "../../services"; +import { handleApiError, useAutosizeTextArea } from '../../utils'; +import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation, postReviewImages } from "../../services"; import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading } from '../../components'; import RatingsCard from '../../components/Card/RatingsCard'; import { useSelector } from 'react-redux'; import { UserRootState } from '../../store/type'; import { DEFAULT_AVATAR_IMG } from '../../constants/default'; import { IHttpResponse } from '../../types/common'; -import { ImagePlus } from 'lucide-react' +import { ImagePlus, Globe, MapPin, Smile, Heart, MessageCircle } from 'lucide-react' import ReactTextareaAutosize from 'react-textarea-autosize'; +import { MenuIcon } from '../../../src/components/Icons/MenuIcon'; +import ScheduleCard from '../../components/Card/ScheduleCard'; +import FacilitiesCard from '../../components/Card/FacilitiesCard'; + const SORT_TYPE = [ 'highest rated', @@ -46,7 +49,41 @@ function LocationDetail() { const [reviewValue, setReviewValue] = useState({ review_textArea: '', score_input: '', + title: '', + rasa: '', + suasana: '', + pelayanan: '', + kebersihan: '', }) + const [uploadedImages, setUploadedImages] = useState<{ file: File; preview: string }[]>([]) + const [uploadLightboxOpen, setUploadLightboxOpen] = useState(false) + const [uploadLightboxIndex, setUploadLightboxIndex] = useState(0) + const [reviewLightboxOpen, setReviewLightboxOpen] = useState(false) + const [reviewLightboxIndex, setReviewLightboxIndex] = useState(0) + const imageInputRef = useRef(null) + + function handleImageSelect(e: ChangeEvent) { + const input = e.target as HTMLInputElement + if (!input.files) return + const newFiles = Array.from(input.files).map(file => ({ + file, + preview: URL.createObjectURL(file), + })) + setUploadedImages(prev => [...prev, ...newFiles]) + input.value = '' + } + + function removeImage(index: number) { + setUploadedImages(prev => { + URL.revokeObjectURL(prev[index].preview) + return prev.filter((_, i) => i !== index) + }) + } + + function openUploadLightbox(index: number) { + setUploadLightboxIndex(index) + setUploadLightboxOpen(true) + } const [isLoading, setIsLoading] = useState(true) const [currentIndex, setCurrentIndex] = useState(0); const currentImage = locationImages?.images[currentIndex]?.src || locationDetail.detail.thumbnail || ""; @@ -69,11 +106,11 @@ function LocationDetail() { } }) } catch (error) { - let err = error as AxiosError; - if (err.response?.status == 404) { + const err = error as IHttpResponse; + if (err.status == 404) { navigate("/") } - alert(error) + alert(handleApiError(error)) } } @@ -158,10 +195,22 @@ function LocationDetail() { submitted_by: Number(user.id), is_from_critic: user.is_critics, comments: reviewValue.review_textArea, + title: reviewValue.title, }) + if (uploadedImages.length > 0) { + try { + await postReviewImages(data.id, uploadedImages.map(img => img.file)) + } catch (imgErr) { + console.log('Image upload failed:', imgErr) + alert('Review posted, but images failed to upload. You can try again.') + } + uploadedImages.forEach(img => URL.revokeObjectURL(img.preview)) + setUploadedImages([]) + } + setPageState({ ...pageState, enable_post: false, on_submit_loading: false }) - setReviewValue({ review_textArea: '', score_input: '' }) + setReviewValue({ review_textArea: '', score_input: '', title: '', rasa: '', suasana: '', pelayanan: '', kebersihan: '' }) setCurrentUserReview({ id: data.id, comments: data.comments, @@ -175,10 +224,8 @@ function LocationDetail() { }) setUpdatePage(true) } catch (error) { - let err = error as AxiosError; - console.log(err) - const str = handleAxiosError(err) - alert(str) + console.log(error) + alert(handleApiError(error)) setPageState({ ...pageState, on_submit_loading: false }) } @@ -212,10 +259,11 @@ function LocationDetail() {
-
+

{locationDetail?.detail.name}

+

{locationDetail?.detail.address}

{/* {isLoading ?
} */} - - {isLoading ? ( -
- ) : ( -
-
- {/* Main image display */} -
- setLightboxOpen(true)} - /> - - {locationImages?.images.length > 1 && ( - <> - - - - )} - - {/* Total images badge */} - {locationImages?.images.length > 1 && ( -
- Total images ({locationImages.images.length}) -
- )} -
- - {/* Thumbnail strip */} - {locationImages?.images.length > 1 && ( -
- {locationImages.images.map((image, index) => ( - setCurrentIndex(index)} - /> - ))} -
- )} -
-
- )} -
-
-

DETAILS

- -
-
-
Address:
-
{locationDetail.detail.address} {locationDetail.detail.regency_name}
-
- - -
-
Average Cost
-
Rp 25.000
-
-
- -
-
-
Tags :
-
- {locationDetail.tags.map((x, index) => ( -
- Badge -
- ))} - -
-
-
- ({ - score: Number(data.detail.critic_score), - count: Number(data.detail.critic_count) - })} - getUserData={(data) => ({ - score: Number(data.detail.user_score), - count: Number(data.detail.user_count) - })} - getCriticDetails={() => ({ - environment: 85, - cleanliness: 90, - price: 75, - facility: 80 - })} - getUserDetails={() => ({ - environment: 82, - cleanliness: 88, - price: 70, - facility: 78 - })} + {!isLoading && (() => { + const imgs = locationImages?.images ?? []; + const total = imgs.length; + if (total === 0) return null; + const openAt = (i: number) => { setCurrentIndex(i); setLightboxOpen(true); }; + + const ImageTile = ({ img, index, className }: { img: typeof imgs[0], index: number, className: string }) => ( +
openAt(index)} + > + +
+ + Photo {index + 1} + +
+
+ ); + + return ( +
+ {total === 1 && ( +
+ +
+ )} + + {total === 2 && ( +
+ + +
+ )} + + {total === 3 && ( +
+ + + +
+ )} + + {total >= 4 && ( +
+ {/* Top-left large */} + + {/* Right full-height */} +
openAt(1)} + > + +
+ Photo 2 + {total > 4 && ( + + 🖼 {total.toLocaleString()} + + )} +
+
+ {/* Bottom-left small */} +
openAt(2)} + > + +
+ Photo 3 +
+
+ {/* Bottom-middle small — with "+more" if applicable */} +
openAt(3)} + > + + {total > 4 && ( +
+ +{total - 4} +
+ )} + {total <= 4 && ( +
+ Photo 4 +
+ )} +
+
+ )} +
+ ); + })()} + +
+ + + Website + + + + Menu + + {/* + + - + */} + + +
+
+ ({ + score: Number(data.detail.critic_score), + count: Number(data.detail.critic_count) + })} + getUserData={(data) => ({ + score: Number(data.detail.user_score), + count: Number(data.detail.user_count) + })} + getCriticDetails={() => ({ + environment: 85, + cleanliness: 90, + price: 75, + facility: 80 + })} + getUserDetails={() => ({ + environment: 82, + cleanliness: 88, + price: 70, + facility: 78 + })} + /> +
+
+ console.log('suggest edit')} + /> +
+
+
@@ -394,86 +496,251 @@ function LocationDetail() { {!user.username ?
SIGN IN TO REVIEW
: -
-
+
-
- - - + {/* Avatar + Username */} +
+ + {user.username} + {currentUserReview && ( + <> + | +
+ {user.is_critics ? "CRITIC SCORE" : "USER SCORE"} + {currentUserReview.score} +
+ + )} +
+ + {currentUserReview ? ( +
+ {currentUserReview.created_at?.Valid && ( + + Written {new Date(String(currentUserReview.created_at.Time)).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} + + )} + + {currentUserReview.title && ( +

{currentUserReview.title}

+ )} + + {currentUserReview.images && currentUserReview.images.length > 0 && ( +
+ {currentUserReview.images.slice(0, 4).map((img, i) => ( +
{ setReviewLightboxIndex(i); setReviewLightboxOpen(true); }} + > + {`review-${i}`} + {i === 3 && currentUserReview.images!.length > 4 && ( +
+ +{currentUserReview.images!.length - 4} MORE +
+ )} +
+ ))} +
+ )} + + {/* Review text — clamped with "Show Full Review" */} +
+
+ +
+
+ + {/* Footer: likes, comments, show full review */} +
+
+ + +
+ +
+ ) : ( + <> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
- + {pageState.is_score_rating_panic_msg && ( +
{pageState.is_score_rating_panic_msg}
+ )} -
- {currentUserReview ? -
-

{currentUserReview.score}

-
-
+ {/* Hidden file input */} + + + + + {uploadedImages.length > 0 && ( +
+
+ +
+ {uploadedImages.map((img, i) => ( +
+ {/* Click image to open lightbox */} + {`upload-${i}`} openUploadLightbox(i)} + /> + {/* X button — top-right corner, always visible */} + +
+ ))}
- : - <> - + + setReviewValue({ ...reviewValue, title: (e.target as HTMLInputElement).value })} + className="w-full bg-transparent border border-gray-600 rounded-lg px-3 py-2 text-sm outline-none focus:border-gray-400 transition-colors placeholder:text-quartenary" + /> +
+ +
+ +
+ -
/ score
- {pageState.is_score_rating_panic_msg && -
{pageState.is_score_rating_panic_msg}
- } - - - } -
-
- -
- {currentUserReview ? - - : - - } -
-
-
- -

add image

-
-
- - {pageState.on_submit_loading ? - - : - - - POST - - - }
-
-
-
+
+ {pageState.on_submit_loading ? ( + + ) : ( + pageState.enable_post && ( + + Post + + ) + )} +
+ + )} +
}
@@ -552,47 +819,50 @@ function LocationDetail() { {locationDetail.users_review.length > 0 ? <> {locationDetail.users_review.map(x => ( -
-
- - - -
- -
-
{x.score}
-
-
-
-
-
- + {/* Header: avatar + username + score */} +
+ + {x.username} + | +
+ USER SCORE + {x.score} +
-
- -
- - -
Instagram
-
+ + {/* Title */} + {x.title && ( +

{x.title}

+ )} + + {/* Images */} + {x.images && x.images.length > 0 && ( +
+ {x.images.slice(0, 4).map((img, i) => ( +
+ {`review-img-${i}`} + {i === 3 && x.images!.length > 4 && ( +
+ +{x.images!.length - 4} MORE +
+ )} +
+ ))}
+ )} + + {/* Comment */} +
+
))} @@ -630,6 +900,18 @@ function LocationDetail() { close={() => setLightboxOpen(false)} slides={locationImages?.images} /> + setUploadLightboxOpen(false)} + index={uploadLightboxIndex} + slides={uploadedImages.map(img => ({ src: img.preview }))} + /> + setReviewLightboxOpen(false)} + index={reviewLightboxIndex} + slides={currentUserReview?.images?.map(img => ({ src: img.src })) ?? []} + />
) } diff --git a/src/pages/LocationDetail/types.ts b/src/pages/LocationDetail/types.ts index f3b4bf0..af4dffc 100755 --- a/src/pages/LocationDetail/types.ts +++ b/src/pages/LocationDetail/types.ts @@ -37,13 +37,15 @@ export function emptyLocationDetail(): ILocationDetail { export interface LocationReviewsResponse { id: number, + title?: string, score: number, comments: string, user_id: number, username: string, user_avatar: string | null, created_at: string, - updated_at: string + updated_at: string, + images?: Array<{ id: number, src: string }> } export interface LocationDetailResponse { @@ -84,6 +86,7 @@ export function emptyLocationResponse(): LocationResponse { export type CurrentUserLocationReviews = { id: number, + title?: string, comments: string, is_from_critic: boolean, is_hided: boolean, @@ -92,4 +95,5 @@ export type CurrentUserLocationReviews = { submitted_by: number, created_at: NullValueRes<"Time", string>, updated_at: NullValueRes<"Time", string>, + images?: Array<{ id: number, src: string }>, } \ No newline at end of file diff --git a/src/pages/NewsEvent/index.tsx b/src/pages/NewsEvent/index.tsx index a89846e..9a22e62 100755 --- a/src/pages/NewsEvent/index.tsx +++ b/src/pages/NewsEvent/index.tsx @@ -2,7 +2,7 @@ import { ChangeEvent, TargetedEvent, useEffect, useRef, useState } from "preact/ import { DefaultButton, NavigationSeparator } from "../../components"; import { News } from "../../domains/NewsEvent"; import { getNewsServices, postNewsService } from "../../../src/services"; -import { AxiosError } from "axios"; + import { DEFAULT_LOCATION_THUMBNAIL_IMG } from "../../../src/constants/default"; import { isUrl, useAutosizeTextArea } from "../../../src/utils"; import ReactTextareaAutosize from "react-textarea-autosize"; @@ -41,7 +41,7 @@ function NewsEvent() { } console.log(news) } catch (error) { - let err = error as AxiosError; + const err = error as IHttpResponse; if (!err.status) { alert('Server is in trouble, probably dead RIP'); } diff --git a/src/pages/UserProfile/index.tsx b/src/pages/UserProfile/index.tsx index aab2eb4..5a29cdb 100755 --- a/src/pages/UserProfile/index.tsx +++ b/src/pages/UserProfile/index.tsx @@ -82,7 +82,7 @@ function UserProfile() {

{user.username}

{/*
@@ -147,6 +147,7 @@ function UserProfile() { diff --git a/src/pages/UserSettings/index.tsx b/src/pages/UserSettings/index.tsx index 5b21469..0c77af8 100755 --- a/src/pages/UserSettings/index.tsx +++ b/src/pages/UserSettings/index.tsx @@ -9,7 +9,7 @@ import { SocialMediaEnum } from "../../types/common"; import { SocialMedia, UserInfo } from "../../../src/domains/User"; import './styles.css' import { deleteUserAvatarService, patchUserAvatarService, patchUserInfoService } from "../../../src/services/users"; -import { AxiosError } from "axios"; + import { useDispatch } from "react-redux"; import { authAdded } from "../../features/auth/authSlice/authSlice"; @@ -93,8 +93,7 @@ function UserSettings() { dispatch(authAdded(userStore)); setIsLoading(false) } catch(err) { - let error = err as AxiosError; - console.log(error) + console.log(err) setIsLoading(false) } @@ -116,8 +115,7 @@ function UserSettings() { setIsLoading(false) } catch(err) { setIsLoading(false) - const error = err as AxiosError - console.log(error) + console.log(err) } } @@ -138,8 +136,8 @@ function UserSettings() { setIsLoading(false) }catch(err) { setIsLoading(false) - let error = err as AxiosError; - alert(error) + console.log(err) + alert(err) } } diff --git a/src/services/config.ts b/src/services/config.ts index 9fc9085..756cfb7 100755 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -20,10 +20,8 @@ export async function client(config: FetchConfig): Promise<{ data: T; s credentials: withCredentials ? 'include' : 'same-origin', }; - // Handle body data if (data) { if (data instanceof FormData) { - // Remove Content-Type header for FormData to let browser set it with boundary const headersObj = fetchOptions.headers as Record; delete headersObj['Content-Type']; fetchOptions.body = data; diff --git a/src/services/index.ts b/src/services/index.ts index 3784e2b..b8c50c9 100755 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -7,7 +7,7 @@ import { } from "./locations"; import { getImagesByLocationService } from "./images" import { createAccountService, loginService, logoutService } from "./auth"; -import { postReviewLocation, getCurrentUserLocationReviewService } from "./review"; +import { postReviewLocation, postReviewImages, getCurrentUserLocationReviewService } from "./review"; import { getRegionsService, getProvincesService, getRegenciesService} from "./regions"; import { getUserStatsService } from "./users"; import { getNewsServices, postNewsService} from "./news"; @@ -31,6 +31,7 @@ export { getImagesByLocationService, postReviewLocation, + postReviewImages, getCurrentUserLocationReviewService, getNewsServices, diff --git a/src/services/review.ts b/src/services/review.ts index 62a83c4..40e61d6 100755 --- a/src/services/review.ts +++ b/src/services/review.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from "@tanstack/react-query"; import { client } from "./config"; -import { GET_CURRENT_USER_REVIEW_LOCATION_URI, POST_REVIEW_LOCATION_URI } from "../constants/api"; +import { GET_CURRENT_USER_REVIEW_LOCATION_URI, POST_REVIEW_IMAGES_URI, POST_REVIEW_LOCATION_URI } from "../constants/api"; import { IHttpResponse } from "src/types/common"; interface postReviewLocationReq { @@ -9,7 +9,8 @@ interface postReviewLocationReq { score: number, is_from_critic: boolean, is_hided: boolean, - location_id: number + location_id: number, + title: string } // API Functions @@ -63,7 +64,16 @@ async function getCurrentUserLocationReviewService(location_id: number): Promise } } +async function postReviewImages(reviewId: number, files: File[]) { + const form = new FormData() + form.append('review_id', String(reviewId)) + files.forEach(f => form.append('images', f)) + const response = await client({ method: 'POST', url: POST_REVIEW_IMAGES_URI, data: form, withCredentials: true }) + return response.data +} + export { postReviewLocation, + postReviewImages, getCurrentUserLocationReviewService, } \ No newline at end of file diff --git a/src/types/common.ts b/src/types/common.ts index d13f322..b25ffce 100755 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -35,11 +35,11 @@ export interface IndonesiaRegionsInfo { } export enum LocationType { - Beach = "beach", - AmusementPark = "amusement park", + TraditionalMarket = "traditional market", + Mall = "mall", + Recreation = "recreation", Culinary = "culinary", - HikingCamping = "hiking / camping", - Other = "other" + Accommodation = "accommodation", } // https://www.similarweb.com/top-websites/indonesia/computers-electronics-and-technology/social-networks-and-online-communities/#:~:text=facebook.com%20ranked%20number%201,Media%20Networks%20websites%20in%20Indonesia. diff --git a/src/utils/common.ts b/src/utils/common.ts index 954df98..8fc5073 100755 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,9 +1,14 @@ -import { AxiosError } from "axios"; import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" -export function handleAxiosError(error: AxiosError) { - return error.response?.data +export function handleApiError(error: unknown): string { + if (error instanceof Error) { + return error.message + } + if (typeof error === 'object' && error !== null && 'message' in error) { + return String((error as { message: unknown }).message) + } + return 'An unexpected error occurred' } export function enumKeys(obj: O): K[] { diff --git a/src/utils/index.ts b/src/utils/index.ts index e8acf60..d83f550 100755 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,9 +1,9 @@ import useAutosizeTextArea from "./useAutosizeTextArea"; -import { handleAxiosError, enumKeys, isUrl } from "./common"; +import { handleApiError, enumKeys, isUrl } from "./common"; export { useAutosizeTextArea, - handleAxiosError, + handleApiError, enumKeys, isUrl } \ No newline at end of file diff --git a/src/utils/useIsMobile.ts b/src/utils/useIsMobile.ts new file mode 100644 index 0000000..a0b3f4d --- /dev/null +++ b/src/utils/useIsMobile.ts @@ -0,0 +1,12 @@ +import { useState, useEffect } from 'preact/hooks'; + +export function useIsMobile(breakpoint = 768) { + const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint); + useEffect(() => { + const mq = window.matchMedia(`(max-width: ${breakpoint - 1}px)`); + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, [breakpoint]); + return isMobile; +}