Compare commits

...

15 Commits

Author SHA1 Message Date
ae26e4f282 remove evorfliew 2023-09-20 21:26:39 +07:00
e454c00847 add location detail 2023-09-20 21:26:25 +07:00
2c7f1cf7a0 add new font size variable 2023-09-20 21:24:33 +07:00
8eb25a2a4a add lightbox 2023-09-20 21:21:43 +07:00
3efee9fb61 fix type and and anchor 2023-09-17 20:39:47 +07:00
7be88c798f add location route 2023-09-17 20:39:25 +07:00
925a0f2922 add location route 2023-09-17 20:39:19 +07:00
5cdf8624d6 add best locationsA 2023-09-17 16:29:53 +07:00
819527ca20 add new navigation routes 2023-09-17 16:26:46 +07:00
976cdd1fc3 add links for header 2023-09-17 16:26:20 +07:00
bd6baa69a5 fix type 2023-09-17 16:25:32 +07:00
8e84411fa4 change separator style 2023-09-17 16:24:15 +07:00
ae942b8b7c fix types 2023-09-17 16:20:50 +07:00
2157534002 remove unused 2023-09-17 16:20:39 +07:00
1ecefbf60a Integrate with get recent locations ratings in home page 2023-09-14 22:49:29 +07:00
31 changed files with 1173 additions and 85 deletions

View File

@ -10,7 +10,9 @@
},
"dependencies": {
"axios": "^1.5.0",
"preact": "^10.16.0"
"preact": "^10.16.0",
"react-router-dom": "^6.16.0",
"yet-another-react-lightbox": "^3.12.2"
},
"devDependencies": {
"@preact/preset-vite": "^2.5.0",

View File

@ -4,6 +4,12 @@
} */
.divider {
border-bottom-width: 1px;
padding-bottom: 3px;
border-color: #38444d;
}
.content {
max-width: 1440px;
margin: 0 auto;
@ -11,13 +17,20 @@
text-align: center;
}
.main-content {
padding: 20px 25px;
text-align: left;
}
.logo {
height: 6em;
padding: 1.5em;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.preact:hover {
filter: drop-shadow(0 0 2em #673ab8aa);
}
@ -30,6 +43,26 @@
color: #888;
}
.users-score {
color: #a8adb3;
font-size: 0.875rem;
line-height: 1.25rem;
}
@media screen and(max-width: 425px) {
.users-score {
font-size: .75rem;
line-height: 1rem;
}
.users-score-bar p {
font-size: 0.75rem;
line-height: 1rem;
}
}
@layer components {
.text-prime {
@apply text-secondary

View File

@ -1,11 +1,29 @@
import { Route, Routes } from 'react-router-dom'
import { BrowserRouter as Router } from 'react-router-dom'
import './app.css'
import { Home } from './pages'
import { DefaultLayout } from './layouts'
import routes from './routes'
import "yet-another-react-lightbox/styles.css";
export function App() {
return (
<>
<Home />
<Router>
<Routes>
<Route element={<DefaultLayout />}>
{routes.map(({ path, name, element}) => (
<>
<Route
path={path}
id={name}
element={element}
/>
</>
))}
</Route>
</Routes>
</Router>
</>
)
}

View File

@ -30,7 +30,9 @@ function Header() {
return (
<header>
<div className="flex flex-row content">
<a href={"/"}>
<h1 className={`title ${dropdown ? 'title-dropdown' : ""}`}>Hilingin</h1>
</a>
<form onSubmit={onSearchSubmit} className={`search-input ${dropdown ? "search-input-dropdown" : ""}`}>
<label>
<input
@ -48,13 +50,13 @@ function Header() {
</div>
<div className={`nav-container ${!dropdown ? "nav-container-disabled" : ""}`}>
{dropdown &&
<a href="#" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Home</a>
<a href="/" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Home</a>
}
<a href="#" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Best places</a>
<a href="#" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Discover</a>
<a href="/best-places" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Top Places</a>
<a href="/discover" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Discover</a>
<a href="#" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Trending Places</a>
<a href="#" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Stories</a>
<a href="#" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>News / Events</a>
<a href="/stories" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Stories</a>
<a href="/news-events" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>News / Events</a>
<a href="#" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Forum</a>
<a href="#" className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>Sign in</a>
</div>

View File

@ -99,7 +99,7 @@ label:before {
.navLink {
padding: 0px 30px;
font-size: 14px;
font-size: 0.8em;
}
@ -115,8 +115,3 @@ label:before {
max-width: '100%';
text-align: 'center'; */
}
.navLink:hover{
border-bottom-width: 2px;
margin-bottom: -2px;
}

View File

@ -0,0 +1,7 @@
function DefaultSeparator() {
return (
<div class={"flex flex-row justify-between divider mb-2"} />
)
}
export default DefaultSeparator;

View File

@ -1,8 +1,4 @@
.divider {
border-bottom-width: 1px;
padding-bottom: 3px;
border-color: #38444d;
}
a:hover {
color: #92a8b7;

View File

@ -1,9 +1,11 @@
import Header from "./Header";
import SeparatorWithAnchor from "./Separator/WithAnchor";
import DefaultSeparator from "./Separator/Default";
import Footer from './Footer/';
export {
Header,
SeparatorWithAnchor,
DefaultSeparator,
Footer
}

23
src/constants/api.ts Normal file
View File

@ -0,0 +1,23 @@
const BASE_URL = "http://localhost:8888"
const SIGNUP_URI = `${BASE_URL}/user/signup`
const GET_LIST_LOCATIONS_URI = `${BASE_URL}/locations`;
const GET_LIST_TOP_LOCATIONS = `${BASE_URL}/locations/top-ratings`
const GET_LIST_RECENT_LOCATIONS_RATING_URI = `${BASE_URL}/locations/recent`
const GET_LOCATION_URI = `${BASE_URL}/location`;
const GET_LOCATION_TAGS_URI = `${BASE_URL}/location/tags`
const GET_IMAGES_BY_LOCATION_URI = `${BASE_URL}/images/location`
export {
BASE_URL,
SIGNUP_URI,
GET_LIST_RECENT_LOCATIONS_RATING_URI,
GET_LIST_TOP_LOCATIONS,
GET_LIST_LOCATIONS_URI,
GET_LOCATION_URI,
GET_LOCATION_TAGS_URI,
GET_IMAGES_BY_LOCATION_URI,
}

View File

@ -1,16 +1,12 @@
import { ComponentChildren } from "preact";
import { Footer, Header } from "../../components";
import { Outlet } from "react-router-dom";
type ChildrenProps = {
children: ComponentChildren
}
function DefaultLayout({ children }: ChildrenProps) {
function DefaultLayout() {
return (
<>
<Header />
<main style={{ overflow: 'hidden' }}>
{children}
<main>
<Outlet />
</main>
<Footer />
</>

View File

@ -0,0 +1,217 @@
import { useEffect, useState } from "preact/hooks";
import { getListTopLocationsService } from "../../services";
import { DefaultSeparator } from "../../components";
import { TargetedEvent } from "preact/compat";
import './style.css';
import { useNavigate } from "react-router-dom";
interface TopLocation {
row_number: Number,
id: Number,
name: String,
thumbnail: NullValueRes<"String", 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,
}
const REGIONS = [
'All',
'Sumatera',
'Jawa',
'Kalimantan',
'Nusa Tenggara',
'Maluku',
'Papua',
'Sulawesi',
];
const REVIEWERS_TYPE =[
'All',
'Critics',
'Users'
]
const MIN_REVIEWS = [
2,
5,
7,
11
]
function BestLocation() {
const [page, setPage] = useState<number>(1);
const [topLocations, setTopLocations] = useState<Array<TopLocation>>([])
const [pageState, setPageState] = useState({
filterScoreType: 'all',
filterScoreTypeidx: 1,
filterRegionTypeName: 'All',
filterRegionType: 0,
})
const navigate = useNavigate()
async function getTopLocations() {
try {
const res = await getListTopLocationsService({ page: page, page_size: 20, order_by: pageState.filterScoreTypeidx, region_type: pageState.filterRegionType })
setTopLocations(res.data)
} catch (err) {
console.log(err)
}
}
function onChangeReviewType(e: TargetedEvent<HTMLAnchorElement>, reviewer_type: string, i: number) {
e.preventDefault()
setPageState({...pageState, filterScoreType: reviewer_type, filterScoreTypeidx: i })
}
function onChangeRegionType(e: TargetedEvent<HTMLAnchorElement>, region_name: string, type: number) {
e.preventDefault();
setPageState({ ...pageState, filterRegionTypeName: region_name, filterRegionType: type})
}
function onNavigateToDetail(
id: Number,
critic_count: Number,
critic_score: Number,
user_count: Number,
user_score: Number,
) {
navigate(`/location/${id}`, { state: { user_score: user_score, user_count: user_count, critic_score: critic_score, critic_count: critic_count }})
}
useEffect(() => {
getTopLocations()
}, [pageState])
return (
<>
<div className={'content main-content mt-3'}>
<section name={"Top locations header"}>
<h1 className={'text-3xl mb-5 font-bold'}>Top Locations</h1>
<div className={'regions-dropdown text-xs pr-3 inline-block'}>
<p className={'inline-block'}>Regions</p>
<a style={{ cursor: 'pointer' }}>
<p className={'ml-2 inline-block'}>{pageState.filterRegionTypeName}</p>
<svg style={{ display: 'inline-block' }} fill='white' xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-345 240-585l56-56 184 184 184-184 56 56-240 240Z" /></svg>
</a>
<div className={'dropdown-content text-sm'}>
{REGIONS.map((x, index) => (
<a onClick={(e) => onChangeRegionType(e, x, index) } className={'block pt-1'}>{x}</a>
))}
</div>
</div>
<div className={'regions-dropdown text-xs pr-3 inline-block'}>
<p className={'inline-block'}>Min. Reviews</p>
<a style={{ cursor: 'pointer' }}>
<p className={'ml-2 inline-block'}>All</p>
<svg style={{ display: 'inline-block' }} fill='white' xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-345 240-585l56-56 184 184 184-184 56 56-240 240Z" /></svg>
</a>
<div className={'dropdown-content-min-reviews text-sm'}>
{MIN_REVIEWS.map(x => (
<a className={'block pt-1'}>{x}</a>
))}
</div>
</div>
<div className={'regions-dropdown text-xs pr-3 inline-block'}>
<p className={'inline-block'}>Tags</p>
<a style={{ cursor: 'pointer' }}>
<p className={'ml-2 inline-block'}>All</p>
<svg style={{ display: 'inline-block' }} fill='white' xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-345 240-585l56-56 184 184 184-184 56 56-240 240Z" /></svg>
</a>
<div className={'dropdown-content text-sm'}>
{/* {REGIONS.map(x => (
<a className={'block pt-1'}>{x}</a>
))} */}
</div>
</div>
<DefaultSeparator />
</section>
<section name={'Main content'}>
<div className={'flex flex-row pt-10 pb-10'}>
<div className={'mr-5'} style={{ flex: 1}}>
{topLocations.map(x => (
<>
{/* UNCOMMENT....IF THERES A PURPOSE FOR RIGHT THREE DOTS */}
{/* <div style={{ float: 'right', cursor: 'pointer' }}>
<a className={'text-xl'}>
...
</a>
</div> */}
<div className={'mb-2 best-locations-title'}>
<a className={'text-xl'} href={`/location/${x.id}`} onClick={() => onNavigateToDetail(x.id, x.critic_count, x.critic_score, x.user_count, x.user_score)}>{x.row_number}.{x.name}</a>
</div>
<div style={{ maxWidth: 200, maxHeight: 200, margin: '0 30px 30px 10px', float: 'left' }}>
<a href={`/location/${x.id}`} onClick={() => onNavigateToDetail(x.id, x.critic_count, x.critic_score, x.user_count, x.user_score)}>
<img src={x.thumbnail.String.toString()} loading={'lazy'} style={{ width: '100%', objectFit: 'cover', height: '100%', aspectRatio: '1/1' }} />
</a>
</div>
<div className={'text-md font-bold'}>{x.regency_name}</div>
<div className={'text-xs mb-2'}>{x.address}</div>
<div>$$$ (IDR 1000-12000)</div>
<a href={x.google_maps_link} target={'_'}><div className={'text-sm mt-2 items-center'}><svg style={{ display: 'inline-block', marginBottom: 3}} xmlns="http://www.w3.org/2000/svg" height="12" fill={'white'} viewBox="0 -960 960 960" width="12 "><path d="M480-480q33 0 56.5-23.5T560-560q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 33 23.5 56.5T480-480Zm0 294q122-112 181-203.5T720-552q0-109-69.5-178.5T480-800q-101 0-170.5 69.5T240-552q0 71 59 162.5T480-186Zm0 106Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-480Z"/></svg>Maps Location</div></a>
<div className={'mt-4'}>
<div className={'text-xs bg-secondary'} style={{ width: 160, display: 'inline-block', borderRadius: 5 }}>
<div className={'text-center p-1 bg-tertiary text-primary'} style={{ borderTopRightRadius: 5, borderTopLeftRadius: 5}}>CRITICS SCORE</div>
<div className={"flex flex-row items-center p-2"}>
<div className={'mr-3 users-score-bar'}>
<p className={`text-xl text-center ${x.critic_score !== 0 ? 'font-bold' : ''}`}>{x.critic_score !== 0 ? Number(x.critic_score) / Number(x.critic_count) * 10 : "N/A"}</p>
<div className={"mt-1"} style={{ height: 4, width: 40, backgroundColor: "#72767d" }}>
<div style={{ height: 4, width: ` ${x.critic_count !== 0 ? Number(x.critic_score) / Number(x.critic_count) * 10 : 0}%`, backgroundColor: 'green' }} />
</div>
</div>
<p className={'text-xs users-score'}>{x.critic_count} reviews</p>
</div>
</div>
<div className={'text-xs bg-secondary ml-3'} style={{ width: 160, display: 'inline-block', borderRadius: 5 }}>
<div className={'text-center p-1 bg-tertiary text-primary'} style={{ borderTopLeftRadius: 5, borderTopRightRadius: 5}}>USERS SCORE</div>
<div className={"flex flex-row items-center p-2"}>
<div className={'mr-3 users-score-bar'}>
<p className={`text-xl text-center ${x.user_score !== 0 ? 'font-bold' : ''}`}>{x.user_score !== 0 ? x.user_score : "N/A" }</p>
<div className={"mt-1"} style={{ height: 4, width: 40, backgroundColor: "#72767d" }}>
<div style={{ height: 4, width: ` ${x.user_score !== 0 ? x.user_score : 0}%`, backgroundColor: 'green' }} />
</div>
</div>
<p className={'text-xs users-score'}>{x.user_count} reviews</p>
</div>
</div>
</div>
<div style={{ clear: 'both'}}/>
</>
))}
</div>
<div className={'p-4 bg-secondary'} style={{ minWidth: 300}}>
<div className={'h-30 bg-primary p-4'} style={{ position: 'sticky', alignSelf: 'flex-start', top: 10}}>
{REVIEWERS_TYPE.map((x, idx) => (
<a
onClick={(e) => onChangeReviewType(e, x.toLowerCase(), idx+1)}
href={'#'}
style={ pageState.filterScoreType == x.toLowerCase() ? { pointerEvents: 'none'} : ''}
>
<div className={`pt-1 pb-1 ${pageState.filterScoreType == x.toLowerCase() ? 'pl-1 bg-tertiary selected-reviewer-filter' : ''}`}>{x} Score</div>
</a>
))}
</div>
</div>
</div>
</section>
</div>
</>
)
}
export default BestLocation;

View File

@ -0,0 +1,57 @@
.dropdown-content {
display: none;
position: absolute;
background-color: #202225;
min-width: 120px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
}
.dropdown-content-min-reviews {
margin-left: 60px;
display: none;
position: absolute;
background-color: #202225;
min-width: 70px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
}
.best-locations-title a:hover {
cursor: pointer;
color: white;
border-bottom: 1px solid white;
}
.regions-dropdown:hover .dropdown-content {
display: block;
}
.regions-dropdown:hover .dropdown-content-min-reviews {
display: block;
}
.dropdown-content a {
cursor: pointer;
padding: 5px;
}
.dropdown-content a:hover {
background-color: rgb(73, 73, 73);
color: white;
}
.dropdown-content-min-reviews a {
cursor: pointer;
padding-left: 10px;
}
.dropdown-content-min-reviews a:hover {
background-color: rgb(73, 73, 73);
color: white;
}
a .selected-reviewer-filter:hover{
color: white;
cursor: default;
}

View File

@ -0,0 +1,10 @@
function Discovery() {
return(
<>
<h1>Best PLaces</h1>
</>
)
}
export default Discovery;

View File

@ -1,21 +1,23 @@
import { DefaultLayout } from '../../layouts/';
import { SeparatorWithAnchor } from '../../components';
import data from '../../datas/home.json';
import news from '../../datas/recent_news_event.json';
import popular from '../../datas/popular.json';
import critics_users_pick from '../../datas/critics_users_best_pick.json';
import popular_user_review from '../../datas/popular_user_reviews.json';
import './style.css';
import { useEffect, useState } from 'preact/hooks';
import { getListRecentLocationsRatingsService } from '../../services';
import { useNavigate } from 'react-router-dom';
type NewPlaces = {
id: Number,
name: string,
thumbnail: string,
location: string,
critic_rating: Number,
critic_voters: Number,
user_rating: Number,
user_voters: Number
thumbnail: NullValueRes<'String', string>,
regency_name: String,
province_name: String,
critic_score: Number,
critic_count: Number,
user_score: Number,
user_count: Number
}
type News = {
@ -27,40 +29,77 @@ type News = {
}
function Home() {
const [recentLocations, setRecentLocations] = useState<Array<NewPlaces>>([])
// const [isLoading, setIsLoading] = useState<boolean>(true)
const navigate = useNavigate()
async function getRecentLocations() {
try {
const locations = await getListRecentLocationsRatingsService(12)
setRecentLocations(locations.data)
// setIsLoading(false)
} catch(error) {
console.log(error)
}
}
function onNavigateToDetail(
id: Number,
critic_count: Number,
critic_score: Number,
user_count: Number,
user_score: Number,
) {
navigate(`/location/${id}`, { state: { user_score: user_score, user_count: user_count, critic_score: critic_score, critic_count: critic_count } })
}
useEffect(() => {
getRecentLocations()
},[])
return (
<DefaultLayout>
<>
<div className="content main-content mt-3">
{/* RECENTLY ADDED SECTION */}
<section about={"Recently added places"} className={'mt-3'}>
<SeparatorWithAnchor pageLink='#' pageName='recently added' secondLink='#' />
{data.new_places.map((x: NewPlaces) => (
{recentLocations.map((x) => (
<div className={"recently-added-section-card"}>
<a onClick={() => onNavigateToDetail(x.id, x.critic_count, x.critic_score, x.user_count, x.user_score)}>
<div className={'border-secondary recently-img-container'}>
<img alt={x.name} src={x.thumbnail} loading="eager" style={{ width: '100%', height: '100%' }} />
<img alt={x.name} src={x.thumbnail.String.toString()} loading="lazy" style={{ width: '100%', height: '100%' }} />
</div>
</a>
<div className={"border-primary pb-2 location-container text-sm mb-2 mt-2"}>
<p className={'location-title'}>{x.name}</p>
<p className={'text-xs mt-1'}>{x.location}</p>
<p className={'text-xs mt-1'}>{x.regency_name}, {x.province_name}</p>
</div>
{ x.critic_count !== 0 &&
<div className={"flex flex-row items-center mb-3"}>
<div className={'mr-3 users-score-bar'}>
<p className={'text-sm text-center'}>{x.critic_rating}</p>
<p className={'text-sm text-center'}>{x.critic_score}</p>
<div style={{ height: 4, width: 30, backgroundColor: "#72767d"}}>
<div style={{ height: 4, width: `${x.critic_rating}%`, backgroundColor: 'green' }} />
<div style={{ height: 4, width: `${x.critic_score}%`, backgroundColor: 'green' }} />
</div>
</div>
<p className={"users-score"}>critic score ({x.critic_voters})</p>
<p className={"users-score"}>critic score ({x.critic_count})</p>
</div>
}
{ x.user_score !== 0 &&
<div className={"flex flex-row items-center"}>
<div className={'mr-3 users-score-bar'}>
<p className={'text-sm text-center'}>{x.user_rating}</p>
<p className={'text-sm text-center'}>{x.user_score}</p>
<div style={{ height: 4, width: 30, backgroundColor: "#72767d" }}>
<div style={{ height: 4, width: ` ${x.user_rating}%`, backgroundColor: 'green' }} />
<div style={{ height: 4, width: ` ${x.user_score}%`, backgroundColor: 'green' }} />
</div>
</div>
<p className={'users-score'}>user score ({x.user_voters})</p>
<p className={'users-score'}>user score ({x.user_count})</p>
</div>
}
</div>
))}
</section>
@ -75,7 +114,7 @@ function Home() {
{news.data.map((x: News) => (
<div class={"text-sm news-card"}>
<div className={"image-news-container"}>
<img src={x.thumbnail} className={"news-img"}/>
<img src={x.thumbnail} loading={'lazy'} className={"news-img"}/>
</div>
<a className={'news-link'} target="_blank" href={x.link}>{x.link.split("/")[2].replace(/www\./, '')}</a>
<p className={'mt-2 mb-2'}>{x.header}</p>
@ -100,7 +139,7 @@ function Home() {
{popular_user_review.data.map((x) => (
<div className={'text-sm reviews-container'}>
<div style={{ float: 'left', width: 120, margin: '0 12px 10px 0' }}>
<img src={x.thumbnail} style={{ width: '100%' }} />
<img src={x.thumbnail} loading={'lazy'} style={{ width: '100%' }} />
</div>
<p className={"text-sm location-titles"}>{x.place_name}</p>
<p className={'text-xs mb-2'}>{x.location}</p>
@ -149,7 +188,7 @@ function Home() {
{critics_users_pick.critics.map((x) => (
<div className={"pt-2 text-sm"}>
<div className={'mr-2 critics-users-image'}>
<img src={x.thumbnail} style={{ height: '100%', width: '100%', borderRadius: 3}} />
<img src={x.thumbnail} loading={'lazy'} style={{ height: '100%', width: '100%', borderRadius: 3}} />
</div>
<p className={'location-title'}>{x.name}</p>
<p className={'text-xs location-province location-title'}>{x.location}</p>
@ -187,7 +226,7 @@ function Home() {
</div>
</section>
</div>
</DefaultLayout>
</>
)
}

View File

@ -1,8 +1,3 @@
.main-content {
padding: 20px 25px;
text-align: left;
}
.recently-added-section-card {
padding: 10px 1% 15px;
display: inline-block;
@ -11,6 +6,10 @@
width: 16.6%;
}
.recently-added-section-card a:hover {
cursor: pointer;
}
.location-container {
text-align: left;
border-bottom-width: 1px;
@ -59,12 +58,6 @@
float: left;
}
.users-score {
color: #a8adb3;
font-size: 0.875rem;
line-height: 1.25rem;
}
.critics-users-rating-container {
margin-top: 0.75rem;
}
@ -185,15 +178,4 @@
height: 100px;
}
.users-score {
font-size: .75rem;
line-height: 1rem;
}
.users-score-bar p {
font-size: 0.75rem;;
line-height: 1rem;
}
}

View File

@ -0,0 +1,79 @@
.header-link{
font-size: 0.7em;
padding-bottom: 5px;
border-bottom: 1px solid #38444d;
}
.header-link a:hover{
color: white;
cursor: pointer;
}
.image-stack {
display: grid;
position: relative;
grid-template-columns: repeat(12, 1fr);
}
.image-stack__item--bottom {
grid-column: -3 / 1;
grid-row: 1;
}
.image-stack__item--middle {
margin-left: 10px;
grid-column: -2 / 1;
grid-row: 1;
padding-top: 2%;
z-index: 1;
}
.image-stack__item--top {
grid-row: 1;
grid-column: -1 / 2;
padding-top: 4%;
z-index: 2;
}
img {
width: 100%;
display: block;
}
.location-detail-container {
padding: 15px;
width: 35%;
vertical-align: top;
border: 1px solid #38444d
}
.location-detail-container div span {
font-size: 12px;
color: #a8adb3
}
.tags-box {
display: inline-block;
background-color: #484848;
border-radius: 3;
}
.tags-box a:hover {
color: white;
border-bottom: 1px solid white;
}
@media screen and (max-width: 380px) {
.header-link {
white-space: nowrap;
width: 100%;
overflow-x: scroll;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: none;
padding: 0 10px;
}
.header-link::-webkit-scrollbar {
display: none;
}
}

View File

@ -0,0 +1,229 @@
import { useLocation, useParams } from 'react-router-dom';
import { getImagesByLocationService, getLocationService } from "../../services";
import { useEffect, useState } from 'preact/hooks';
import './index.css';
import Lightbox from 'yet-another-react-lightbox';
import useCallbackState from '../../types/state-callback';
import { EmptyLocationDetailResponse, LocationDetailResponse, LocationResponse, emptyLocationResponse } from './types';
function LocationDetail() {
const [locationDetail, setLocationDetail] = useCallbackState<LocationDetailResponse>(EmptyLocationDetailResponse)
const [locationImages, setLocationImages] = useState<LocationResponse>(emptyLocationResponse())
const [lightboxOpen, setLightboxOpen] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(true)
const { state } = useLocation();
const { id } = useParams()
async function getLocationDetail() {
try {
const res = await getLocationService(Number(id))
setLocationDetail(res.data, (val) => {
getImage(val.detail.thumbnail.String.toString())
})
} catch (err) {
console.log(err)
}
}
async function getImage(thumbnail?: String) {
try {
const res = await getImagesByLocationService({ page: 1, page_size: 15, location_id: Number(id) })
res.data.images.push({ src: thumbnail })
setLocationImages(res.data)
} catch (error) {
console.log(error)
}
setIsLoading(false)
}
useEffect(() => {
getLocationDetail()
}, [])
return (
<>
<div className={'content main-content mt-3'}>
<section name={"HEADER LINK"}>
<div className={'header-link text-tertiary'}>
<a style={{ display: 'inline-block' }}>OVERVIEW</a>
<a className={'ml-4'} style={{ display: 'inline-block' }}>USER REVIEWS</a>
<a className={'ml-4'} style={{ display: 'inline-block' }}>CRITIC REVIEWS</a>
<a className={'ml-4'} style={{ display: 'inline-block' }}>COMMENTS</a>
</div>
</section>
<section name={'LOCATION HEADER'}>
<div className={'pb-5'} style={{ borderBottom: '1px solid #38444d'}}>
<div className={'font-bold mt-5 text-2xl'}>
<h1>{locationDetail?.detail.name}</h1>
</div>
{isLoading ?
<div className={'mt-3'} style={{ width: 250, height: 250, backgroundColor: 'gray', float: 'left' }} />
:
<div className={'inline-block'} style={{ maxWidth: 320 }}>
<a
onClick={() => setLightboxOpen(true)}
className={'mt-3'}
style={{ display: 'grid', position: 'relative', gridTemplateColumns: 'repeat(12,1fr)', cursor: 'zoom-in' }}
>{Number(locationImages?.total_image) > 0 &&
<div class="image-stack__item image-stack__item--top">
<img src={locationDetail.detail.thumbnail.String.toString()} alt="" style={{ aspectRatio: '1/1' }} />
{locationImages?.images.length > 1 &&
<div className={'text-xs p-2 bg-primary'} style={{ position: 'absolute', bottom: 0, right: 0 }}>
Total images ({locationImages?.images.length})
</div>
}
</div>
}
{locationImages?.images.length > 1 &&
<div class="image-stack__item image-stack__item--middle">
<img src={locationImages?.images[0].src} alt="" style={{ aspectRatio: '1/1' }} />
</div>
}
<div class="image-stack__item image-stack__item--bottom" style={Number(locationImages?.total_image) > 1 ? {} : { gridColumn: '13/1' }}>
<img src={Number(locationImages?.total_image) > 1 ? locationImages?.images[1].src.toString() : locationDetail.detail.thumbnail.String.toString()} alt="" style={{ aspectRatio: '1/1' }} />
</div>
</a>
</div>
}
<div className={'inline-block'} style={{ verticalAlign: 'top', padding: '0 2%', width: '30%', minWidth: 310 }}>
<div className={'p-4 bg-secondary mt-3'} style={{ width: '100%', height: 120, borderTopLeftRadius: 10, borderTopRightRadius: 10 }}>
<div className={'font-bold ml-1 text-xs'}>CRITICS SCORE</div>
<div className={'text-4xl text-center mt-2 mr-4'} style={{ width: 95, float: 'left' }}>
{state.critic_count !== 0 ? state.critic_score : "NR"}
<div className={"items-center p-2"}>
<div className={'mr-3 users-score-bar'}>
<div className={"mt-1"} style={{ height: 4, width: 80, backgroundColor: "#72767d" }}>
<div style={{ height: 4, width: ` ${state.critic_count !== 0 ? Number(state.critic_score) / Number(state.critic_count) * 10 : 0}%`, backgroundColor: 'green' }} />
</div>
</div>
</div>
</div>
{state.critic_count !== 0 &&
<div className={'ml-14 text-sm'}>
Based on {state.critic_count} reviews
</div>
}
</div>
<div className={'p-4 bg-secondary mt-1 inline-block'} style={{ width: '100%', height: 120, borderBottomLeftRadius: 10, borderBottomRightRadius: 10 }}>
<div className={'font-bold ml-2 text-xs'}>USERS SCORE</div>
<div className={'text-4xl text-center mt-2'} style={{ width: 95, float: 'left' }}>
{state.user_count !== 0 ? state.user_score : "NR"}
<div className={"items-center p-2"}>
<div className={'mr-3 users-score-bar'}>
<div className={"mt-1"} style={{ height: 4, width: 80, backgroundColor: "#72767d" }}>
<div style={{ height: 4, width: ` ${state.user_count !== 0 ? Number(state.user_score) / Number(state.user_score) * 10 : 0}%`, backgroundColor: 'green' }} />
</div>
</div>
</div>
</div>
{state.user_count !== 0 &&
<div className={'ml-4'}>
Based on {state.user_count} reviews
</div>
}
</div>
</div>
<div className={'inline-block mt-3 bg-primary text-sm location-detail-container'}>
<div className={'pb-1'} style={{ borderBottom: '1px solid #38444d' }}>
<h2 className={'inline-block font-bold text-xs'} style={{ letterSpacing: .9 }}>DETAILS</h2>
<div className={''} style={{ float: 'right', fontSize: 10, letterSpacing: .9 }}>
<a href="#">SUBMIT CORRECTION</a>
</div>
</div>
<div className={'mt-2 capitalize'}>
<span>address: </span> {locationDetail.detail.address} {locationDetail.detail.regency_name}
</div>
<div className={'mt-1 capitalize'}>
<span>province: </span> <a href={'#'}> {locationDetail.detail.province_name}</a>
</div>
<div className={'mt-1 capitalize'}>
<span>region: </span> <a href={'#'}> {locationDetail.detail.region_name}</a>
</div>
<div className={'mt-1 capitalize'}>
<span>average cost: </span> IDR 25.0000
</div>
<div className={'mt-1 text-md'}>
<a href={locationDetail.detail.google_maps_link.toString()} style={{ borderBottom: '1px solid white' }} target={'_'}> Maps Location</a>
</div>
<div className={'mt-1'}>
<span>Tags: </span>
</div>
{locationDetail.tags.map(x => (
<div className={'p-1 text-xs tags-box mr-1'}>
<a href={'#'}>{x}</a>
</div>
))
}
<div className={'p-1 text-xs tags-box'}>
<a href={'#'}>+ add tags</a>
</div>
</div>
</div>
</section>
<section>
<div className={'mt-5'} style={{ tableLayout: 'fixed', display: 'table', width: '100%'}}>
<div className={'wideLeft'} style={{ textAlign: 'left', paddingRight: 20, maxWidth: 1096, minWidth: 680, display: 'table-cell', position: 'relative', verticalAlign: 'top', width: '100%', boxSizing: 'border-box' }}>
<div className={'reviewContainer p-4'} style={{ backgroundColor: '#2f3136' }}>
<div className={'reviewBoxContent'} style={{ width: '75%', margin: '0 auto' }}>
<div className={'userImage mr-3'} style={{ width: 50, float: 'left' }}>
<a href={'#'}>
<img loading={'lazy'} src={'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'} />
</a>
</div>
<div style={{ display: 'block' }}>
<a href={'#'}>user</a>
</div>
<div style={{ margin: '5px 0 10px' }}>
<div style={{ float: 'left' }}>
<input
type={'text'}
pattern={"\d*"}
style={{ fontSize: 12, backgroundColor: '#40444b', textAlign: 'center', width: 40, height: 20, lineHeight: 18, border: '1px solid #38444d' }}
maxLength={3}
placeholder={"0-100"}
autoComplete={'off'}
/>
</div>
<div style={{ clear: 'both' }} />
</div>
<div className={'mt-3'} style={{ width: '100%' }}>
<textarea style={{ border: 'none', overflow: 'auto', outline: 'none', boxShadow: 'none', backgroundColor: '#40444b' }}></textarea>
</div>
<div style={{ textAlign: 'right', width: "100%" }}>
<div style={{ display: 'inline-block', fontSize: 11, verticalAlign: 'middle', margin: '0 10px 0 0', letterSpacing: .5 }}>
<a>Review Guidelines</a>
</div>
<span className={'text-xxs p-1'} style={{ backgroundColor: 'gray', letterSpacing: 1 }}>
<a href={'#'}>
POST
</a>
</span>
</div>
</div>
</div>
</div>
<div style={{ display: 'table-cell', position: 'relative', verticalAlign: 'top', width: 330, textAlign: 'left', padding: 15, boxSizing: 'border-box', backgroundColor: "gray", height: 1080 }}>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Reprehenderit cumque aliquam doloribus in reiciendis? Laborum, ea assumenda, tempora dolore placeat aspernatur, cumque totam sequi debitis dolor nam eligendi suscipit aliquid?
</div>
</div>
</section>
<Lightbox
open={lightboxOpen}
close={() => setLightboxOpen(false)}
slides={locationImages?.images}
/>
</div>
</>
)
}
export default LocationDetail;

View File

@ -0,0 +1,69 @@
import { SlideImage } from "yet-another-react-lightbox"
export interface ILocationDetail {
id: Number,
name: String,
address: String,
google_maps_link: String,
thumbnail: NullValueRes<"String", String>,
submitted_by: Number,
regency_name: String,
province_name: String,
region_name: String,
submitted_by_user: String
}
export function emptyLocationDetail(): ILocationDetail {
return {
id: 0,
address: '',
google_maps_link: '',
thumbnail: { String: '', Valid: false },
name: '',
province_name: '',
regency_name: '',
region_name: '',
submitted_by: 0,
submitted_by_user: ''
}
}
export interface LocationDetailResponse {
detail: ILocationDetail,
tags: Array<String>
}
export function EmptyLocationDetailResponse(): LocationDetailResponse {
return {
detail: emptyLocationDetail(),
tags: []
}
}
export interface LocationImage extends SlideImage {
id: Number,
src: string,
created_at: String,
uploaded_by: String
}
export function emptyLocationImage(): LocationImage {
return {
id: 0,
src: '',
created_at: '',
uploaded_by: ''
}
}
export interface LocationResponse {
total_image: Number,
images: Array<LocationImage>
}
export function emptyLocationResponse(): LocationResponse {
return {
total_image: 0,
images: [emptyLocationImage()]
}
}

View File

@ -0,0 +1,10 @@
function NewsEvent() {
return(
<>
<h1>Best PLaces</h1>
</>
)
}
export default NewsEvent;

View File

@ -0,0 +1,10 @@
function Story() {
return(
<>
<h1>Best PLaces</h1>
</>
)
}
export default Story;

View File

@ -1,5 +1,17 @@
import Home from "./Home";
import BestLocation from "./BestLocations";
import Discovery from "./Discovery";
import Story from "./Stories";
import NewsEvent from "./NewsEvents";
import LocationDetail from "./LocationDetail";
export {
Home
Home,
BestLocation,
LocationDetail,
Discovery,
Story,
NewsEvent
}

43
src/routes/index.tsx Normal file
View File

@ -0,0 +1,43 @@
import {
BestLocation,
Discovery,
Home,
LocationDetail,
NewsEvent,
Story
} from '../pages';
const routes = [
{
path: "/",
name: "Home",
element: <Home />
},
{
path: "/best-places",
name: "Home",
element: <BestLocation />
},
{
path: "/discover",
name: "Home",
element: <Discovery />
},
{
path: "/stories",
name: "Home",
element: <Story />
},
{
path: "/news-events",
name: "Home",
element: <NewsEvent />
},
{
path: "/location/:id",
name: "LocationDetail",
element: <LocationDetail />
}
]
export default routes;

19
src/services/config.ts Normal file
View File

@ -0,0 +1,19 @@
import axios, { AxiosPromise, AxiosRequestConfig } from "axios";
import {BASE_URL} from '../constants/api'
export const client = (props: AxiosRequestConfig): AxiosPromise => axios({
method: props.method,
baseURL: `${BASE_URL}`,
url: props.url,
headers: props.headers,
data: props.data
})
// export const authClient = (props: AxiosRequestConfig) => axios({
// method: props.method,
// baseURL: `${BASE_URL}`,
// url: props.url,
// headers: {
// 'Authorization':
// }
// })

36
src/services/images.ts Normal file
View File

@ -0,0 +1,36 @@
import { GET_IMAGES_BY_LOCATION_URI } from "../constants/api"
import { client } from "./config"
import statusCode from "./status-code"
const initialState: any = {
data: null,
error: null
}
interface getImagesReq extends GetRequestPagination {
location_id?: Number
}
async function getImagesByLocationService({ page, page_size, location_id }: getImagesReq) {
const newState = { ...initialState }
const url = `${GET_IMAGES_BY_LOCATION_URI}?location_id=${location_id}&page=${page}&page_size=${page_size}`
try {
const response = await client({ method: 'GET', url: url})
switch (response.request.status) {
case statusCode.OK:
newState.data = response.data;
return newState
default:
newState.error = response.data;
return newState
}
} catch (error) {
console.log(`GET IMAGE BY LOCATION SERVICE ERROR: ${error}`)
}
}
export {
getImagesByLocationService
}

18
src/services/index.ts Normal file
View File

@ -0,0 +1,18 @@
import {
getListLocationsService,
getListRecentLocationsRatingsService,
getListTopLocationsService,
getLocationService,
getLocationTagsService,
} from "./locations";
import { getImagesByLocationService } from "./images"
export {
getListLocationsService,
getListRecentLocationsRatingsService,
getListTopLocationsService,
getLocationService,
getLocationTagsService,
getImagesByLocationService,
}

111
src/services/locations.ts Normal file
View File

@ -0,0 +1,111 @@
import { GET_LIST_LOCATIONS_URI, GET_LIST_RECENT_LOCATIONS_RATING_URI, GET_LIST_TOP_LOCATIONS, GET_LOCATION_TAGS_URI, GET_LOCATION_URI } from "../constants/api";
import { client } from "./config";
import statusCode from "./status-code";
const initialState: any = {
data: null,
error: null
}
interface getListLocationsArg extends GetRequestPagination {
order_by?: number,
region_type?: number
}
async function getListLocationsService({ page, page_size }: getListLocationsArg) {
const newState = { ...initialState };
const url = `${GET_LIST_LOCATIONS_URI}?page=${page}&page_size=${page_size}`
try {
const response = await client({ method: 'GET', url: url })
switch (response.request.status) {
case statusCode.OK:
newState.data = response.data;
return newState;
default:
newState.error = response.data;
return newState
}
} catch (error) {
console.log(error)
}
}
async function getListRecentLocationsRatingsService(page_size: Number) {
const newState = { ...initialState };
const url = `${GET_LIST_RECENT_LOCATIONS_RATING_URI}?page_size=${page_size}`
try {
const response = await client({ method: 'GET', url: url })
switch (response.request.status) {
case statusCode.OK:
newState.data = response.data;
return newState;
default:
newState.error = response.data;
return newState
}
} catch (error) {
console.log(error)
}
}
async function getListTopLocationsService({ page, page_size, order_by, region_type }: getListLocationsArg) {
const newState = { ...initialState };
const url = `${GET_LIST_TOP_LOCATIONS}?page=${page}&page_size=${page_size}&order_by=${order_by}&region_type=${region_type}`
try {
const response = await client({ method: 'GET', url: url })
switch (response.request.status) {
case statusCode.OK:
newState.data = response.data;
return newState;
default:
newState.error = response.data;
return newState
}
} catch (error) {
console.log(error)
}
}
async function getLocationService(id: Number) {
const newState = { ...initialState };
const url = `${GET_LOCATION_URI}/${id}`
try {
const response = await client({ method: 'GET', url: url })
switch (response.request.status) {
case statusCode.OK:
newState.data = response.data;
return newState;
default:
newState.error = response.data;
return newState;
}
} catch (error) {
console.log(error)
}
}
async function getLocationTagsService(id: Number) {
const newState = { ...initialState };
const url = `${GET_LOCATION_TAGS_URI}/${id}`
try {
const response = await client({ method: 'GET', url: url })
switch (response.request.status) {
case statusCode.OK:
newState.data = response.data;
return newState;
default:
newState.error = response.data;
return newState;
}
} catch (error) {
console.log(error)
}
}
export {
getListLocationsService,
getListRecentLocationsRatingsService,
getListTopLocationsService,
getLocationTagsService,
getLocationService
}

View File

@ -0,0 +1,10 @@
export default {
OK: 200,
CREATED: 201,
ACCEPTED: 202,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
NOT_ALLOWED: 405,
NOT_FOUND: 404,
SERVER_ERROR: 500,
};

7
src/types/common.ts Normal file
View File

@ -0,0 +1,7 @@
type BaseNullValueRes = { Valid: boolean };
type NullValueRes<Key extends string, _> = BaseNullValueRes & Record<Key, string | number>
interface GetRequestPagination {
page: number,
page_size: number,
}

View File

@ -0,0 +1,30 @@
// https://medium.com/geekculture/usecallbackstate-the-hook-that-let-you-run-code-after-a-setstate-operation-finished-25f40db56661
import { useEffect, useRef, useState } from "react";
type CallBackType<T> = (updatedValue: T) => void;
type SetStateType<T> = T | ((prev: T) => T);
type RetType = <T>(
initialValue: T | (() => T)
) => [T, (newValue: SetStateType<T>, callback?: CallBackType<T>) => void];
const useCallbackState: RetType = <T>(initialValue: T | (() => T)) => {
const [state, _setState] = useState<T>(initialValue);
const callbackQueue = useRef<CallBackType<T>[]>([]);
useEffect(() => {
callbackQueue.current.forEach((cb) => cb(state));
callbackQueue.current = [];
}, [state]);
const setState = (newValue: SetStateType<T>, callback?: CallBackType<T>) => {
_setState(newValue);
if (callback && typeof callback === "function") {
callbackQueue.current.push(callback);
}
};
return [state, setState];
};
export default useCallbackState;

View File

@ -17,6 +17,7 @@ export default {
lato: ['Lato', 'sans-serif']
},
fontSize: {
xxs: ['0.65rem', { lineHeight: '.85rem' }],
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.5rem' }],

View File

@ -434,6 +434,11 @@
"@prefresh/utils" "^1.2.0"
"@rollup/pluginutils" "^4.2.1"
"@remix-run/router@1.9.0":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.9.0.tgz#9033238b41c4cbe1e961eccb3f79e2c588328cf6"
integrity sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA==
"@rollup/pluginutils@^4.1.1", "@rollup/pluginutils@^4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d"
@ -1050,6 +1055,21 @@ queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
react-router-dom@^6.16.0:
version "6.16.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.16.0.tgz#86f24658da35eb66727e75ecbb1a029e33ee39d9"
integrity sha512-aTfBLv3mk/gaKLxgRDUPbPw+s4Y/O+ma3rEN1u8EgEpLpPe6gNjIsWt9rxushMHHMb7mSwxRGdGlGdvmFsyPIg==
dependencies:
"@remix-run/router" "1.9.0"
react-router "6.16.0"
react-router@6.16.0:
version "6.16.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.16.0.tgz#abbf3d5bdc9c108c9b822a18be10ee004096fb81"
integrity sha512-VT4Mmc4jj5YyjpOi5jOf0I+TYzGpvzERy4ckNSvSh2RArv8LLoCxlsZ2D+tc7zgjxcY34oTz2hZaeX5RVprKqA==
dependencies:
"@remix-run/router" "1.9.0"
read-cache@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"
@ -1229,3 +1249,8 @@ yaml@^2.1.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b"
integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==
yet-another-react-lightbox@^3.12.2:
version "3.12.2"
resolved "https://registry.yarnpkg.com/yet-another-react-lightbox/-/yet-another-react-lightbox-3.12.2.tgz#10b6a023fe6469e51d52e881715f00d5f2e5e82b"
integrity sha512-sFypE74Rjjl7WbzKSrGeZ7lbcgduOam/BKj8kqzx/ciC4dJyYdE1SG0dur9oDq/gZiY3VGJh+PYGbq6ZwmPFmA==