Save changes before submodule conversion

This commit is contained in:
goro 2026-03-21 11:14:39 +02:00
parent c51f187793
commit 6b4d10e8a0
22 changed files with 1074 additions and 526 deletions

View File

@ -15,7 +15,7 @@
overflow: auto; overflow: auto;
outline: none; outline: none;
box-shadow: none; box-shadow: none;
background-color: #40444b; background-color: #202225;
width: 100%; width: 100%;
min-height: 100px; min-height: 100px;
overflow-y: hidden; overflow-y: hidden;

View File

@ -9,11 +9,22 @@ import { persistore, store } from './store/config'
import { PersistGate } from 'redux-persist/integration/react' import { PersistGate } from 'redux-persist/integration/react'
import { AdminProtectedRoute, UserProtectedRoute } from './routes/ProtectedRoute' import { AdminProtectedRoute, UserProtectedRoute } from './routes/ProtectedRoute'
import { getRoutes } from './routes'; import { getRoutes } from './routes';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
})
export function App() { export function App() {
const { routes } = getRoutes(); const { routes } = getRoutes();
return ( return (
<QueryClientProvider client={queryClient}>
<Provider store={store}> <Provider store={store}>
<PersistGate persistor={persistore}> <PersistGate persistor={persistore}>
<Router> <Router>
@ -63,5 +74,6 @@ export function App() {
</Router> </Router>
</PersistGate> </PersistGate>
</Provider> </Provider>
</QueryClientProvider>
) )
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@ -0,0 +1,160 @@
interface RatingData {
score: number;
count: number;
}
interface DetailRatings {
environment: number;
cleanliness: number;
price: number;
facility: number;
}
interface RatingsCardProps<T> {
data: T;
getCriticData: (data: T) => RatingData;
getUserData: (data: T) => RatingData;
getCriticDetails?: (data: T) => DetailRatings;
getUserDetails?: (data: T) => DetailRatings;
}
const RatingsCard = <T,>({
data,
getCriticData,
getUserData,
getCriticDetails,
getUserDetails
}: RatingsCardProps<T>) => {
const criticData = getCriticData(data);
const userData = getUserData(data);
const criticDetails = getCriticDetails?.(data);
const userDetails = getUserDetails?.(data);
const formatCount = (count: number): string | number => {
return count >= 1000
? `${(count / 1000).toFixed(1).replace(/\.0$/, '')}k`
: count;
};
const calculateScore = (score: number, count: number): string | number => {
return count !== 0 ? Math.floor(score / count) : "NR";
};
return (
<div className="flex flex-col gap-1 mt-2 items-center">
{/* Critics Score Section */}
<div className="flex gap-2 max-[768px]:flex-col max-[768px]:w-full">
<div className="p-4 bg-secondary rounded-lg w-[180px] max-[768px]:w-full">
<div className="font-bold text-xs mb-2 text-center">CRITICS SCORE</div>
<div className="text-4xl text-center my-2">
{calculateScore(criticData.score, criticData.count)}
</div>
<div className="h-1 w-20 bg-[#72767d] mx-auto mb-2">
<div
className="h-1 bg-brand-green"
style={{ width: `${criticData.count !== 0 ? criticData.score : 0}%` }}
/>
</div>
{criticData.count !== 0 && (
<div className="text-sm text-center">
Based on {formatCount(criticData.count)} reviews
</div>
)}
</div>
{/* Critics Detail Cards */}
{criticDetails && (
<div className="flex gap-2 max-[768px]:w-full max-[768px]:justify-between">
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">ENVIRONMENT</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{criticDetails.environment}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${criticDetails.environment}%` }} />
</div>
</div>
</div>
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">PRICE</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{criticDetails.price}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${criticDetails.price}%` }} />
</div>
</div>
</div>
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">FACILITY</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{criticDetails.facility}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${criticDetails.facility}%` }} />
</div>
</div>
</div>
</div>
)}
</div>
{/* Users Score Section */}
<div className="flex gap-2 max-[768px]:flex-col max-[768px]:w-full">
<div className="p-4 bg-secondary rounded-lg w-[180px] max-[768px]:w-full">
<div className="font-bold text-xs mb-2 text-center">USERS SCORE</div>
<div className="text-4xl text-center my-2">
{calculateScore(userData.score, userData.count)}
</div>
<div className="h-1 w-20 bg-[#72767d] mx-auto mb-2">
<div
className="h-1 bg-brand-green"
style={{ width: `${userData.count !== 0 ? userData.score / userData.count : 0}%` }}
/>
</div>
{userData.count !== 0 && (
<div className="text-sm text-center">
Based on {formatCount(userData.count)} reviews
</div>
)}
</div>
{/* Users Detail Cards */}
{userDetails && (
<div className="flex gap-2 max-[768px]:w-full max-[768px]:justify-between">
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">ENVIRONMENT</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{userDetails.environment}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${userDetails.environment}%` }} />
</div>
</div>
</div>
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">PRICE</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{userDetails.price}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${userDetails.price}%` }} />
</div>
</div>
</div>
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">FACILITY</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{userDetails.facility}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${userDetails.facility}%` }} />
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default RatingsCard;

View File

@ -1,8 +1,64 @@
import { stripHexcode } from 'emojibase'; import { stripHexcode } from 'emojibase';
import { InterweaveProps, FilterInterface, MatcherInterface, Interweave } from 'interweave'; import { InterweaveProps, FilterInterface, MatcherInterface, Interweave, Matcher, MatchResponse, Node } from 'interweave';
import { IpMatcher, UrlMatcher, EmailMatcher, HashtagMatcher } from 'interweave-autolink'; import { IpMatcher, UrlMatcher, EmailMatcher, HashtagMatcher } from 'interweave-autolink';
import { EmojiMatcher, PathConfig } from 'interweave-emoji'; import { EmojiMatcher, PathConfig } from 'interweave-emoji';
class SevenTVMatcher extends Matcher {
private emotes: Record<string, string> = {
'booba': '01F6N31ETR0004P7N4A9PKS5X9',
'pepeJAM': '5f1f0ea5cf6d2144653d7501',
'OMEGALUL': '5f4b3bc28fb088567e5cbb3b',
'monkaS': '01F78CHJ2G0005TDSTZFBDGMK4',
'Sadge': '5f1f0f61b5e9d35e9a2f8a0e',
'PogU': '5f1f0c1235c7c40e6a3f9c1b',
};
replaceWith(match: string): Node {
const emoteName = match.replace(/:/g, '').trim();
const emoteId = this.emotes[emoteName];
if (emoteId) {
return (
<img
src={`https://cdn.7tv.app/emote/${emoteId}/3x.avif`}
alt={emoteName}
title={emoteName}
style={{
display: 'inline-block',
height: '28px',
verticalAlign: 'middle',
margin: '0 2px'
}}
/>
);
}
return match;
}
asTag(): string {
return 'span';
}
match(value: string): MatchResponse<{}> | null {
const emoteNames = Object.keys(this.emotes).join('|');
// Match emote names wrapped in colons: :emoteName:
const pattern = new RegExp(`:(${emoteNames}):`, 'g');
const result = pattern.exec(value);
if (!result) {
return null;
}
return {
index: result.index,
length: result[0].length,
match: result[0],
valid: true,
};
}
}
const globalFilters: FilterInterface[] = []; const globalFilters: FilterInterface[] = [];
const globalMatchers: MatcherInterface[] = [ const globalMatchers: MatcherInterface[] = [
@ -15,6 +71,7 @@ const globalMatchers: MatcherInterface[] = [
convertShortcode: true, convertShortcode: true,
convertUnicode: true, convertUnicode: true,
}), }),
new SevenTVMatcher('7tv'),
]; ];
function getEmojiPath(hexcode: string, { enlarged }: PathConfig): string { function getEmojiPath(hexcode: string, { enlarged }: PathConfig): string {

View File

@ -67,7 +67,7 @@ function Header() {
navigate(`/location/${val.value}`) navigate(`/location/${val.value}`)
} }
const onLoadSelectOptions = async (inputValue: string) => { const onLoadSelectOptions = async (inputValue: string): Promise<ReactSelectData[]> => {
try { try {
const results = await getSearchLocationService({ const results = await getSearchLocationService({
name: inputValue, name: inputValue,
@ -89,6 +89,7 @@ function Header() {
return result return result
} catch (err) { } catch (err) {
alert(err) alert(err)
return []
} }
} }

View File

@ -1,4 +1,4 @@
const BASE_URL = "http://localhost:8888"; const BASE_URL = import.meta.env.VITE_BASE_URL || "http://192.168.1.13:8888";
const SIGNUP_URI = `${BASE_URL}/user/signup`; const SIGNUP_URI = `${BASE_URL}/user/signup`;
const LOGIN_URI = `${BASE_URL}/user/login`; const LOGIN_URI = `${BASE_URL}/user/login`;

View File

@ -195,7 +195,7 @@ function Discovery() {
))} ))}
</div> </div>
<div className="self-stretch p-2 rounded-md justify-center items-center gap-1 inline-flex"> <div className="self-stretch p-2 rounded-md justify-center items-center gap-1 inline-flex">
<button onClick={() => setFloatFilterOpen(!isFloatFilterOpen)} className="text-center text-green text-sm font-semibold leading-[21px] tracking-tight break-words">Lihat Selengkapnya</button> <button onClick={() => setFloatFilterOpen(!isFloatFilterOpen)} className="text-center text-brand-green text-sm font-semibold leading-[21px] tracking-tight break-words">Lihat Selengkapnya</button>
<FloatFilter <FloatFilter
isOpen={isFloatFilterOpen} isOpen={isFloatFilterOpen}
placement="right" placement="right"

View File

@ -1,7 +1,7 @@
import { LocationCard, SeparatorWithAnchor } from '../../components'; import { LocationCard, SeparatorWithAnchor } from '../../components';
import news from '../../datas/recent_news_event.json'; // import news from '../../datas/recent_news_event.json';
import popular from '../../datas/popular.json'; import popular from '../../datas/popular.json';
import popular_user_review from '../../datas/popular_user_reviews.json'; // import popular_user_review from '../../datas/popular_user_reviews.json';
import './style.css'; import './style.css';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import { getListRecentLocationsRatingsService, getListTopLocationsService } from '../../services'; import { getListRecentLocationsRatingsService, getListTopLocationsService } from '../../services';
@ -114,7 +114,7 @@ function Home() {
{/* START RECENT NEWS / EVENT SECTION */} {/* START RECENT NEWS / EVENT SECTION */}
{/* USE OPEN GRAPH PARSER TO READ OG DATA FROM HTML */} {/* USE OPEN GRAPH PARSER TO READ OG DATA FROM HTML */}
{/* https://github.com/dyatlov/go-opengraph */} {/* https://github.com/dyatlov/go-opengraph */}
<section className={"mt-10"}> {/* <section className={"mt-10"}>
<SeparatorWithAnchor pageLink='#' pageName='Recent News / Event' secondLink='#' /> <SeparatorWithAnchor pageLink='#' pageName='Recent News / Event' secondLink='#' />
<div className={"mt-5"}> <div className={"mt-5"}>
{news.data.map((x: News) => ( {news.data.map((x: News) => (
@ -134,12 +134,12 @@ function Home() {
)) ))
} }
</div> </div>
</section> </section> */}
{/* END RECENT NEWS / EVENT SECTION */} {/* END RECENT NEWS / EVENT SECTION */}
{/* LOCATION CRITICS BEST AND USERS BEST SECTION */} {/* LOCATION CRITICS BEST AND USERS BEST SECTION */}
<section className={"mt-10"}> {/* <section className={"mt-10"}>
<SeparatorWithAnchor pageLink='#' pageName={"Popular user reviews"} secondLink='#' /> <SeparatorWithAnchor pageLink='#' pageName={"Popular user reviews"} secondLink='#' />
<div> <div>
{popular_user_review.data.map((x) => ( {popular_user_review.data.map((x) => (
@ -166,7 +166,7 @@ function Home() {
} }
</div> </div>
</section> </section> */}
{/* START LOCATION CRITICS BEST AND USERS BEST SECTION */} {/* START LOCATION CRITICS BEST AND USERS BEST SECTION */}
@ -178,7 +178,15 @@ function Home() {
{popular.data.map((x) => ( {popular.data.map((x) => (
<div className={"m-2 text-sm col-span-2 md:col-span-1"}> <div className={"m-2 text-sm col-span-2 md:col-span-1"}>
<div className={"mb-2 trending-image-container"}> <div className={"mb-2 trending-image-container"}>
<img src={x.thumbnail} loading={"lazy"} style={{ width: '100%', height: '100%' }} /> <img
src={x.thumbnail}
loading={"lazy"}
style={{ width: '100%', height: '100%' }}
onError={(e) => {
e.currentTarget.src = 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp';
e.currentTarget.onerror = null;
}}
/>
</div > </div >
<p className={"location-title"}>{x.name}</p> <p className={"location-title"}>{x.name}</p>
<p className={"text-xs location-title"}>{x.location}</p> <p className={"text-xs location-title"}>{x.location}</p>

View File

@ -14,11 +14,12 @@ import { AxiosError } from 'axios';
import { handleAxiosError, useAutosizeTextArea } from '../../utils'; import { handleAxiosError, useAutosizeTextArea } from '../../utils';
import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation } from "../../services"; import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation } from "../../services";
import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading } from '../../components'; import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading } from '../../components';
import RatingsCard from '../../components/Card/RatingsCard';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { UserRootState } from '../../store/type'; import { UserRootState } from '../../store/type';
import { DEFAULT_AVATAR_IMG } from '../../constants/default'; import { DEFAULT_AVATAR_IMG } from '../../constants/default';
import './index.css';
import { IHttpResponse } from '../../types/common'; import { IHttpResponse } from '../../types/common';
import { ImagePlus } from 'lucide-react'
import ReactTextareaAutosize from 'react-textarea-autosize'; import ReactTextareaAutosize from 'react-textarea-autosize';
const SORT_TYPE = [ const SORT_TYPE = [
@ -47,6 +48,8 @@ function LocationDetail() {
score_input: '', score_input: '',
}) })
const [isLoading, setIsLoading] = useState<boolean>(true) const [isLoading, setIsLoading] = useState<boolean>(true)
const [currentIndex, setCurrentIndex] = useState(0);
const currentImage = locationImages?.images[currentIndex]?.src || locationDetail.detail.thumbnail || "";
const navigate = useNavigate(); const navigate = useNavigate();
const user = useSelector((state: UserRootState) => state.auth) const user = useSelector((state: UserRootState) => state.auth)
@ -199,151 +202,217 @@ function LocationDetail() {
}, [updatePage, id]) }, [updatePage, id])
return ( return (
<div className={'content main-content mt-3'}> <div className="content main-content mt-3">
<section name={"HEADER LINK"}> <section name={"HEADER LINK"}>
<div className={'header-link text-tertiary'}> <div className="text-[0.7em] pb-1.5 border-b border-[#38444d] text-tertiary whitespace-nowrap w-full overflow-x-scroll overflow-y-hidden [-webkit-overflow-scrolling:touch] [-ms-overflow-style:none] max-[380px]:px-2.5 [&::-webkit-scrollbar]:hidden [&>a:hover]:text-white [&>a:hover]:cursor-pointer">
<a style={{ display: 'inline-block' }}>OVERVIEW</a> <a className="inline-block">OVERVIEW</a>
<a className={'ml-4'} style={{ display: 'inline-block' }}>USER REVIEWS</a> <a className="ml-4 inline-block">USER REVIEWS</a>
<a className={'ml-4'} style={{ display: 'inline-block' }}>CRITIC REVIEWS</a> <a className="ml-4 inline-block">CRITIC REVIEWS</a>
<a className={'ml-4'} style={{ display: 'inline-block' }}>COMMENTS</a> <a className="ml-4 inline-block">COMMENTS</a>
</div> </div>
</section> </section>
<section name={'LOCATION HEADER'}> <section className="pb-5 border-b border-[#38444d]"name={'LOCATION HEADER'}>
<div className={'pb-5'} style={{ borderBottom: '1px solid #38444d' }}> <div>
<div className={'font-bold mt-5 text-2xl'}> <div className="font-bold mt-5 text-2xl">
<h1>{locationDetail?.detail.name}</h1> <h1>{locationDetail?.detail.name}</h1>
</div> </div>
{isLoading ? {/* {isLoading ?
<div className={'mt-3'} style={{ width: 250, height: 250, backgroundColor: 'gray', float: 'left' }} /> <div className="mt-3 w-[250px] h-[250px] bg-gray float-left" />
: :
<div className={'inline-block'} style={{ maxWidth: 320 }}> <div className="inline-block max-w-[320px]">
<a <a
onClick={() => setLightboxOpen(true)} onClick={() => setLightboxOpen(true)}
className={'mt-3'} className="mt-3 grid relative grid-cols-12 cursor-zoom-in"
style={{ display: 'grid', position: 'relative', gridTemplateColumns: 'repeat(12,1fr)', cursor: 'zoom-in' }}
>{Number(locationImages?.total_image) > 0 && >{Number(locationImages?.total_image) > 0 &&
<div class="image-stack__item image-stack__item--top"> <div class="row-start-1 col-start-2 col-end-[-1] pt-[4%] z-[2]">
<img src={locationDetail.detail.thumbnail ? locationDetail.detail.thumbnail : ""} alt="" style={{ aspectRatio: '1/1' }} /> <img src={locationDetail.detail.thumbnail ? locationDetail.detail.thumbnail : ""} alt="" className="aspect-square w-full block" />
{locationImages?.images.length > 1 && {locationImages?.images.length > 1 &&
<div className={'text-xs p-2 bg-primary'} style={{ position: 'absolute', bottom: 0, right: 0 }}> <div className="text-xs p-2 bg-primary absolute bottom-0 right-0">
Total images ({locationImages?.images.length}) Total images ({locationImages?.images.length})
</div> </div>
} }
</div> </div>
} }
{locationImages?.images.length > 1 && {locationImages?.images.length > 1 &&
<div class="image-stack__item image-stack__item--middle"> <div class="ml-2.5 row-start-1 col-start-1 col-end-[-2] pt-[2%] z-[1]">
<img src={locationImages?.images[0].src} alt="" style={{ aspectRatio: '1/1' }} /> <img src={locationImages?.images[0].src} alt="" className="aspect-square w-full block" />
</div> </div>
} }
<div class="image-stack__item image-stack__item--bottom" style={Number(locationImages?.total_image) > 1 ? {} : { gridColumn: '13/1' }}> <div class="row-start-1 col-start-1 col-end-[-3]" style={Number(locationImages?.total_image) > 1 ? {} : { gridColumn: '13/1' }}>
<img src={Number(locationImages?.total_image) > 1 ? locationImages?.images[1].src.toString() : locationDetail.detail.thumbnail!} alt="" style={{ aspectRatio: '1/1' }} /> <img src={Number(locationImages?.total_image) > 1 ? locationImages?.images[1].src.toString() : locationDetail.detail.thumbnail!} alt="" className="aspect-square w-full block" />
</div> </div>
</a> </a>
</div> </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 }}> {isLoading ? (
<div className={'font-bold ml-1 text-xs'}>CRITICS SCORE</div> <div className="mt-3 w-[250px] h-[250px] bg-gray float-left" />
<div className={'text-4xl text-center mt-2 mr-4'} style={{ width: 95, float: 'left' }}> ) : (
{locationDetail.detail.critic_count !== 0 ? Math.floor(Number(locationDetail.detail.critic_score) / Number(locationDetail.detail.critic_count)) : "NR"} <div className="inline-block w-full max-w-[650px]">
<div className={"items-center p-2"}> <div className="mt-3 relative group">
<div className={'mr-3 users-score-bar'}> {/* Main image display */}
<div className={"mt-1"} style={{ height: 4, width: 80, backgroundColor: "#72767d" }}> <div className="relative w-full h-[360px] max-[768px]:h-[240px] border-[#38444d] border-[1px] rounded-lg">
<div style={{ height: 4, width: ` ${locationDetail.detail.critic_count !== 0 ? Number(locationDetail.detail.critic_score) : 0}%`, backgroundColor: 'green' }} /> <img
src={currentImage}
alt=""
className="w-full h-full object-cover block cursor-zoom-in rounded-lg"
onClick={() => setLightboxOpen(true)}
/>
{locationImages?.images.length > 1 && (
<>
<button
onClick={(e) => {
e.stopPropagation();
setCurrentIndex((prev) =>
prev === 0 ? locationImages.images.length - 1 : prev - 1
);
}}
className="absolute left-2 top-1/2 -translate-y-1/2 bg-black/50 text-white p-2 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
</button>
<button
onClick={(e) => {
e.stopPropagation();
setCurrentIndex((prev) =>
prev === locationImages.images.length - 1 ? 0 : prev + 1
);
}}
className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/50 text-white p-2 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
</button>
</>
)}
{/* Total images badge */}
{locationImages?.images.length > 1 && (
<div className="text-xs p-2 bg-primary absolute bottom-0 right-0 rounded-br-md">
Total images ({locationImages.images.length})
</div>
)}
</div>
{/* Thumbnail strip */}
{locationImages?.images.length > 1 && (
<div className="flex gap-2 mt-2 overflow-x-auto">
{locationImages.images.map((image, index) => (
<img
key={index}
src={image.src}
alt=""
className={`w-16 h-16 object-cover cursor-pointer flex-shrink-0 ${index === currentIndex ? 'ring-2 ring-primary' : 'opacity-60'
}`}
onClick={() => setCurrentIndex(index)}
/>
))}
</div>
)}
</div>
</div>
)}
<div className="ml-8 inline-block mt-3 bg-primary text-sm p-[15px] w-[45%] rounded-md align-top border border-[#38444d] h-[420px]">
<div className="pb-1 border-b border-[#38444d]">
<h2 className="inline-block font-bold text-md tracking-[0.9px]">DETAILS</h2>
<div className="float-right text-[12px] tracking-[0.9px]">
<a class="group-hover:opacity-100 transition-opacity duration-200 underline underline-offset-4" href="#">SUBMIT CORRECTION</a>
</div>
</div>
<div className="mt-3">
<div className="font-bold text-md mb-1">Address:</div>
<div className="capitalize text-md mb-3">{locationDetail.detail.address} {locationDetail.detail.regency_name}</div>
</div>
<div className="mt-3">
<div className="font-bold text-md mb-1">Province:</div>
<div className="capitalize text-md mb-3">
<a href={'#'} className="hover:text-white"> {locationDetail.detail.province_name}</a>
</div>
</div>
<div className="mt-3">
<div className="font-bold text-md mb-1">Region:</div>
<div className="capitalize text-md mb-3">
<a href={'#'} className="hover:text-white"> {locationDetail.detail.region_name}</a>
</div>
</div>
<div className="mt-3">
<div className="font-bold text-md mb-1">Average Cost</div>
<div className="text-md mb-3">Rp 25.000</div>
</div>
<div className="mt-3">
<div className="font-bold text-md mb-1 underline">
<a href={locationDetail.detail.google_maps_link.toString()} target={'_'}>Maps Location</a>
</div>
</div>
<div className="mt-3">
<div className="font-bold text-md mb-1">Tags :</div>
<div className="flex flex-wrap gap-1.5">
{locationDetail.tags.map((x, index) => (
<div key={index} className="px-2 py-0.5 text-md bg-[#484848] rounded-[3px] hover:text-white">
<a href={'#'}>Badge</a>
</div>
))}
<div className="px-2 py-0.5 text-md bg-[#484848] rounded-[3px] hover:text-white">
<a href={'#'}>+ add more</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{locationDetail.detail.critic_count !== 0 &&
<div className={'ml-14 text-sm'}>
Based on {locationDetail.detail.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 mr-4'} style={{ width: 95, float: 'left' }}>
{locationDetail.detail.user_count !== 0 ? Math.floor(Number(locationDetail.detail.user_score) / Number(locationDetail.detail.user_count)) : "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: ` ${locationDetail.detail.user_count !== 0 ? Number(locationDetail.detail.user_score) / Number(locationDetail.detail.user_count) : 0}%`, backgroundColor: 'green' }} />
</div>
</div>
</div>
</div>
{locationDetail.detail.user_count !== 0 &&
<div className={'ml-14 text-sm'}>
Based on {locationDetail.detail.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> </div>
<RatingsCard
data={locationDetail}
getCriticData={(data) => ({
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
})}
/>
</section> </section>
<section name={'REVIEWS 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' }}>
{!user.username ?
<div className={'bg-secondary text-center p-3'} style={{ width: '100%' }}><a href={'#'} onClick={handleSignInNavigation} style={{ borderBottom: '1px solid white' }}>SIGN IN</a> TO REVIEW</div>
:
<div name="REVIEW INPUT TEXTAREA" className={'reviewContainer p-4'} style={{ backgroundColor: '#2f3136' }}>
<div className={'review-box-content'}>
<div className={'userImage mr-3'} style={{ width: 55, float: 'left' }}> <section name={'REVIEWS SECTION'}>
<div className="mt-5 table w-full table-fixed">
<div className="text-left pr-5 max-w-[1096px] min-w-[680px] table-cell relative align-top w-full box-border">
{!user.username ?
<div className="bg-secondary text-center p-3 w-full"><a href={'#'} onClick={handleSignInNavigation} className="border-b border-white">SIGN IN</a> TO REVIEW</div>
:
<div name="REVIEW INPUT TEXTAREA" className="p-4 bg-secondary">
<div className="w-3/4 mx-auto max-[1024px]:w-full [&_input]:outline-none">
<div className="w-[55px] float-left mr-3">
<a href={'#'}> <a href={'#'}>
<img loading={'lazy'} src={user.avatar_picture != '' ? user.avatar_picture.toString() : DEFAULT_AVATAR_IMG} style={{ aspectRatio: '1/1' }} /> <img loading={'lazy'} src={user.avatar_picture != '' ? user.avatar_picture.toString() : DEFAULT_AVATAR_IMG} className="aspect-square" />
</a> </a>
</div> </div>
<div style={{ display: 'block' }}> <div className="block">
<a href={'#'}>{user.username}</a> <a href={'#'}>{user.username}</a>
</div> </div>
<div className={'ratingInput'} style={currentUserReview ? { margin: '0 0 10px' } : { margin: '5px 0 10px' }}> <div className={currentUserReview ? "m-0 mb-2.5" : "my-1.5 mx-0"}>
{currentUserReview ? {currentUserReview ?
<div style={{ display: 'inline-block' }}> <div className="inline-block">
<p className={'ml-2'}>{currentUserReview.score}</p> <p className="ml-2">{currentUserReview.score}</p>
<div style={{ height: 4, width: 35, backgroundColor: "#72767d" }}> <div className="h-1 w-[35px] bg-[#72767d]">
<div style={{ height: 4, width: `${currentUserReview.score}%`, backgroundColor: 'green' }} /> <div className="h-1 bg-brand-green" style={{ width: `${currentUserReview.score}%` }} />
</div> </div>
</div> </div>
: :
@ -351,24 +420,24 @@ function LocationDetail() {
<input <input
type={'text'} type={'text'}
pattern={"\d*"} pattern={"\d*"}
style={{ fontSize: 12, backgroundColor: '#40444b', textAlign: 'center', width: 40, height: 20, lineHeight: 18, border: '1px solid #38444d' }} className="text-xs bg-[#40444b] text-center w-10 h-5 leading-[18px] border border-[#38444d]"
maxLength={3} maxLength={3}
value={reviewValue.score_input} value={reviewValue.score_input}
onChange={handleScoreInputChange} onChange={handleScoreInputChange}
placeholder={"0-100"} placeholder={"0-100"}
autoComplete={'off'} autoComplete={'off'}
/> />
<div className={'inline-block text-xs ml-2 text-tertiary'}>/ score</div> <div className="inline-block text-xs ml-2 text-tertiary">/ score</div>
{pageState.is_score_rating_panic_msg && {pageState.is_score_rating_panic_msg &&
<div className={'inline-block text-xs ml-2 text-error'}>{pageState.is_score_rating_panic_msg}</div> <div className="inline-block text-xs ml-2 text-error">{pageState.is_score_rating_panic_msg}</div>
} }
</> </>
} }
<div style={{ clear: 'both' }} /> <div className="clear-both" />
</div> </div>
<div className={'mt-3'} style={{ width: '100%' }}> <div className="mt-3 w-full">
{currentUserReview ? {currentUserReview ?
<CustomInterweave <CustomInterweave
content={currentUserReview.comments} content={currentUserReview.comments}
@ -377,20 +446,24 @@ function LocationDetail() {
<ReactTextareaAutosize <ReactTextareaAutosize
onChange={handleTextAreaChange} onChange={handleTextAreaChange}
ref={textAreaRef} ref={textAreaRef}
className={'p-2 text-area text-sm'} className="p-2 text-area text-sm"
value={reviewValue.review_textArea} value={reviewValue.review_textArea}
/> />
} }
</div> </div>
<div class='flex justify-between'>
<div style={{ textAlign: 'right', width: "100%" }}> <div className='flex hover:text-tertiary text-[white] cursor-pointer'>
<div style={{ display: 'inline-block', fontSize: 11, verticalAlign: 'middle', margin: '0 10px 0 0', letterSpacing: .5 }}> <ImagePlus className='text-inherit mr-1' size={20} />
<p className='text-sm'>add image</p>
</div>
<div className="text-right">
<div className="inline-block text-[11px] align-middle mr-2.5 tracking-[0.5px]">
<a>Review Guidelines</a> <a>Review Guidelines</a>
</div> </div>
{pageState.on_submit_loading ? {pageState.on_submit_loading ?
<SpinnerLoading /> <SpinnerLoading />
: :
<span className={'text-xxs p-1 text-area-button'} style={pageState.enable_post ? '' : { display: 'none' }}> <span className={`text-xxs p-1 bg-gray tracking-[1px] ${!pageState.enable_post ? 'hidden' : ''}`}>
<a href={'#'} onClick={handleSubmitReview}> <a href={'#'} onClick={handleSubmitReview}>
POST POST
</a> </a>
@ -399,68 +472,69 @@ function LocationDetail() {
</div> </div>
</div> </div>
</div> </div>
</div>
} }
<div name={'CRTICITS REVIEW'} style={{ margin: '50px 0', textAlign: 'left' }}> <div name={'CRTICITS REVIEW'} className="my-[50px] mx-0 text-left">
<SeparatorWithAnchor pageName={"critic's review"} pageLink='#' /> <SeparatorWithAnchor pageName={"critic's review"} pageLink='#' />
{locationDetail.critics_review.length > 0 ? {locationDetail.critics_review.length > 0 ?
<> <>
<div className={'criticSortFilter'}> <div className="float-right [&_.dropdownLabel]:cursor-pointer">
<div className={'inline-block text-sm'}>Sort by: </div> <div className="inline-block text-sm">Sort by: </div>
<a className={'dropdownLabel'} onClick={() => setPageState({ ...pageState, show_sort: !pageState.show_sort })}> <a className="dropdownLabel" onClick={() => setPageState({ ...pageState, show_sort: !pageState.show_sort })}>
<p className={'ml-2 inline-block capitalize text-sm'}>{pageState.critic_filter_name}</p> <p className="ml-2 inline-block capitalize text-sm">{pageState.critic_filter_name}</p>
<svg style={{ display: 'inline-block' }} fill={"currentColor"} 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> <svg className="inline-block" fill={"currentColor"} 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> </a>
<div className={'dropdown-content text-sm bg-secondary'} style={pageState.show_sort ? { display: 'block' } : ''}> <div className={`dropdown-content text-sm bg-secondary ${pageState.show_sort ? 'block' : ''}`}>
{SORT_TYPE.map((x, index) => ( {SORT_TYPE.map((x, index) => (
<a onClick={(e) => onChangeCriticsSort(e, x, index)} className={'block pt-1 capitalize'}>{x}</a> <a onClick={(e) => onChangeCriticsSort(e, x, index)} className="block pt-1 capitalize">{x}</a>
))} ))}
</div> </div>
</div> </div>
<div style={{ clear: 'both' }} /> <div className="clear-both" />
{locationDetail.critics_review.map(x => ( {locationDetail.critics_review.map(x => (
<div className={''} style={{ padding: '15px 0' }}> <div className="py-[15px] px-0">
<div style={{ float: 'left' }}> <div className="float-left">
<div style={{ fontSize: 20, marginRight: 20, textAlign: 'center', width: 55, marginBottom: 3 }}> <div className="text-xl mr-5 text-center w-[55px] mb-[3px]">
{x.score} {x.score}
</div> </div>
<div style={{ height: 4, width: 55, position: 'relative', backgroundColor: '#d8d8d8' }}> <div className="h-1 w-[55px] relative bg-[#d8d8d8]">
<div style={{ height: 4, backgroundColor: '#85ce73', width: `${x.score}%` }} /> <div className="h-1 bg-[#85ce73]" style={{ width: `${x.score}%` }} />
</div> </div>
</div> </div>
<div className={'mr-3'} style={{ display: 'inline-block', width: 40 }}> <div className="mr-3 inline-block w-10">
<a href="#"> <a href="#">
<img <img
loading={'lazy'} loading={'lazy'}
style={{ width: '100%' }} className="w-full"
src={x.user_avatar ? x.user_avatar : 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'} src={x.user_avatar ? x.user_avatar : 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'}
/> />
</a> </a>
</div> </div>
<div style={{ display: 'inline-block', verticalAlign: 'top' }}> <div className="inline-block align-top">
<div style={{ fontWeight: 700, fontSize: 16, lineHeight: 'initial' }}> <div className="font-bold text-base leading-none">
<a> <a>
<span>{x.username}</span> <span>{x.username}</span>
</a> </a>
</div> </div>
</div> </div>
<div style={{ fontSize: 15, lineHeight: '24px', margin: '5px 75px 1px' }}> <div className="text-[15px] leading-6 my-[5px] mx-[75px] mb-[1px]">
<CustomInterweave <CustomInterweave
content={x.comments} content={x.comments}
/> />
</div> </div>
<div className={'reviewLinks'} style={{ marginLeft: 72 }}> <div className="ml-[72px]">
<div className={'mr-2'} style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle' }}> <div className="mr-2 min-w-[55px] inline-block align-middle">
<a className={'text-sm'} href={'#'}> <a className="text-sm" href={'#'}>
<svg className={'inline-block mr-1'} fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg> <svg className="inline-block mr-1" fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg>
<div className={'inline-block'}>Video</div> <div className="inline-block">Video</div>
</a> </a>
</div> </div>
<div style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle' }}> <div className="min-w-[55px] inline-block align-middle">
<a className={'text-sm'} href={'#'}> <a className="text-sm" href={'#'}>
<svg className={'inline-block mr-1'} fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg> <svg className="inline-block mr-1" fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg>
<div className={'inline-block'}>Instagram</div> <div className="inline-block">Instagram</div>
</a> </a>
</div> </div>
</div> </div>
@ -469,85 +543,85 @@ function LocationDetail() {
</> </>
: :
<span className={'text-sm italic'}>No Critics review to display</span> <span className="text-sm italic">No Critics review to display</span>
} }
</div> </div>
<div name={'USERS REVIEW'} style={{ margin: '50px 0', textAlign: 'left' }}> <div name={'USERS REVIEW'} className="my-[50px] mx-0 text-left">
<SeparatorWithAnchor pageName={"User's review"} pageLink='#' secondLink={locationDetail.users_review.length > 0 ? '#' : ''} /> <SeparatorWithAnchor pageName={"User's review"} pageLink='#' secondLink={locationDetail.users_review.length > 0 ? '#' : ''} />
{locationDetail.users_review.length > 0 ? {locationDetail.users_review.length > 0 ?
<> <>
{locationDetail.users_review.map(x => ( {locationDetail.users_review.map(x => (
<div style={{ padding: '15px 0' }}> <div className="py-[15px] px-0">
<div className={'mr-5'} style={{ width: 45, float: 'left' }}> <div className="mr-5 w-[45px] float-left">
<a href="#"> <a href="#">
<img <img
loading={'lazy'} loading={'lazy'}
style={{ width: '100%' }} className="w-full"
src={x.user_avatar ? x.user_avatar : 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'} src={x.user_avatar ? x.user_avatar : 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'}
/> />
</a> </a>
</div> </div>
<div> <div>
<div style={{ fontWeight: 700, fontSize: 16, lineHeight: 'initial' }}> <div className="font-bold text-base leading-none">
<a> <a>
<span>{x.username}</span> <span>{x.username}</span>
</a> </a>
</div> </div>
</div> </div>
<div className={'inline-block'}> <div className="inline-block">
<div className={'text-sm text-center'} >{x.score}</div> <div className="text-sm text-center">{x.score}</div>
<div style={{ height: 4, width: 25, position: 'relative', backgroundColor: '#d8d8d8' }}> <div className="h-1 w-[25px] relative bg-[#d8d8d8]">
<div style={{ height: 4, backgroundColor: '#85ce73', width: `${x.score}%` }} /> <div className="h-1 bg-[#85ce73]" style={{ width: `${x.score}%` }} />
</div> </div>
</div> </div>
<div style={{ fontSize: 15, lineHeight: '24px', margin: '10px 65px 1px', wordWrap: 'break-word' }}> <div className="text-[15px] leading-6 my-2.5 mx-[65px] mb-[1px] break-words">
<CustomInterweave <CustomInterweave
content={x.comments} content={x.comments}
/> />
</div> </div>
<div className={'reviewLinks'} style={{ marginLeft: 63 }}> <div className="ml-[63px]">
<div className={'mr-2'} style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle' }}> <div className="mr-2 min-w-[55px] inline-block align-middle">
<a className={'text-sm'} href={'#'}> <a className="text-sm" href={'#'}>
<svg className={'inline-block mr-1'} fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg> <svg className="inline-block mr-1" fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg>
<div className={'inline-block'}>Video</div> <div className="inline-block">Video</div>
</a> </a>
</div> </div>
<div style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle' }}> <div className="min-w-[55px] inline-block align-middle">
<a className={'text-sm'} href={'#'}> <a className="text-sm" href={'#'}>
<svg className={'inline-block mr-1'} fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg> <svg className="inline-block mr-1" fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg>
<div className={'inline-block'}>Instagram</div> <div className="inline-block">Instagram</div>
</a> </a>
</div> </div>
</div> </div>
</div> </div>
))} ))}
<div className={'review-more-button text-center text-sm mt-5'}> <div className="text-center text-sm mt-5 [&>a:hover]:bg-[#38444d] [&>a:hover]:text-white [&>a:hover]:cursor-pointer">
<a style={{ borderRadius: 15, padding: '8px 32px', border: '1px solid #d8d8d8' }}> <a className="rounded-[15px] py-2 px-8 border border-[#d8d8d8]">
More More
</a> </a>
</div> </div>
</> </>
: :
<> <>
<span className={'text-sm italic'}>No users review to display</span> <span className="text-sm italic">No users review to display</span>
</> </>
} }
</div> </div>
<div className={'mb-5'}> <div className="mb-5">
CONTRUBITION CONTRUBITION
<DefaultSeparator /> <DefaultSeparator />
anoeantoeh aoenthaoe aoenth aot anoeantoeh aoenthaoe aoenth aot
</div> </div>
</div> </div>
</div> </div>
<div style={{ clear: 'both' }} /> <div className="clear-both" />
</section> </section>
<section> <section>
<div className={'text-center text-md pt-5 pb-5'}> <div className="text-center text-md pt-5 pb-5">
Added on: 28 May 1988 Added on: 28 May 1988
</div> </div>
</section> </section>

View File

@ -2,7 +2,7 @@ import { NullValueRes } from "../../types/common"
import { SlideImage } from "yet-another-react-lightbox" import { SlideImage } from "yet-another-react-lightbox"
export interface ILocationDetail { export interface ILocationDetail {
id: Number, id: number,
name: String, name: String,
address: String, address: String,
regency_name: String, regency_name: String,
@ -10,11 +10,11 @@ export interface ILocationDetail {
region_name: String, region_name: String,
google_maps_link: String, google_maps_link: String,
thumbnail: string | null, thumbnail: string | null,
submitted_by: Number, submitted_by: number,
critic_score: Number, critic_score: number,
critic_count: Number, critic_count: number,
user_score: Number, user_score: number,
user_count: Number user_count: number
} }
export function emptyLocationDetail(): ILocationDetail { export function emptyLocationDetail(): ILocationDetail {
@ -64,14 +64,14 @@ export function EmptyLocationDetailResponse(): LocationDetailResponse {
} }
export interface LocationImage extends SlideImage { export interface LocationImage extends SlideImage {
id: Number, id: number,
src: string, src: string,
created_at: String, created_at: String,
uploaded_by: String uploaded_by: String
} }
export interface LocationResponse { export interface LocationResponse {
total_image: Number, total_image: number,
images: Array<LocationImage> images: Array<LocationImage>
} }
@ -83,13 +83,13 @@ export function emptyLocationResponse(): LocationResponse {
} }
export type CurrentUserLocationReviews = { export type CurrentUserLocationReviews = {
id: Number, id: number,
comments: string, comments: string,
is_from_critic: boolean, is_from_critic: boolean,
is_hided: boolean, is_hided: boolean,
location_id: Number, location_id: number,
score: Number, score: number,
submitted_by: Number, submitted_by: number,
created_at: NullValueRes<"Time", string>, created_at: NullValueRes<"Time", string>,
updated_at: NullValueRes<"Time", string>, updated_at: NullValueRes<"Time", string>,
} }

View File

@ -58,6 +58,11 @@ function Login() {
}) })
if (res.error) { if (res.error) {
if(res.error.response.status == 409) {
setErrorMsg([{ field: 'username', msg: 'Username Already exist' }])
return;
}
console.log(res.error)
setErrorMsg(res.error.response.data.errors) setErrorMsg(res.error.response.data.errors)
return; return;
} }
@ -96,6 +101,7 @@ function Login() {
</tr> </tr>
</tbody> </tbody>
</table> </table>
{console.log(errorMsg)}
{errorMsg.map(x => ( {errorMsg.map(x => (
<p>{x.msg}</p> <p>{x.msg}</p>
))} ))}

View File

@ -1,55 +1,76 @@
import { AxiosError } from "axios"; import { useMutation, UseMutationOptions } from "@tanstack/react-query";
import { LOGIN_URI, SIGNUP_URI } from "../constants/api"; import { LOGIN_URI, SIGNUP_URI, LOGOUT_URI } from "../constants/api";
import { client } from "./config"; import { client } from "./config";
import { IHttpResponse } from "../types/common"; import { IHttpResponse } from "../types/common";
const initialState: IHttpResponse = {
data: null,
error: AxiosError
}
interface IAuthentication { interface IAuthentication {
username: String username: string
password: String password: string
} }
async function createAccountService({ username, password }: IAuthentication) { // API Functions
const newState = { ...initialState }; const createAccount = async ({ username, password }: IAuthentication) => {
try {
const response = await client({ method: 'POST', url: SIGNUP_URI, data: { username, password }, withCredentials: true }) const response = await client({ method: 'POST', url: SIGNUP_URI, data: { username, password }, withCredentials: true })
newState.data = response.data return response.data
newState.error = null }
return newState
const login = async ({ username, password }: IAuthentication) => {
const response = await client({ method: 'POST', url: LOGIN_URI, data: { username, password }, withCredentials: true })
return response.data
}
const logout = async () => {
const response = await client({ method: 'POST', url: LOGOUT_URI, withCredentials: true })
return response.data
}
// React Query Hooks
export const useCreateAccount = (options?: UseMutationOptions<any, Error, IAuthentication>) => {
return useMutation({
mutationFn: createAccount,
...options
})
}
export const useLogin = (options?: UseMutationOptions<any, Error, IAuthentication>) => {
return useMutation({
mutationFn: login,
...options
})
}
export const useLogout = (options?: UseMutationOptions<any, Error, void>) => {
return useMutation({
mutationFn: logout,
...options
})
}
// Legacy service functions for backward compatibility
async function createAccountService({ username, password }: IAuthentication) {
try {
const data = await createAccount({ username, password })
return { data, error: null }
} catch (error) { } catch (error) {
newState.error = error return { data: null, error }
return newState
} }
} }
async function loginService({ username, password }: IAuthentication) { async function loginService({ username, password }: IAuthentication) {
const newState = { ...initialState };
try { try {
const response = await client({ method: 'POST', url: LOGIN_URI, data: { username, password }, withCredentials: true }) const data = await login({ username, password })
newState.data = response.data return { data, error: null }
newState.error = null
return newState
} catch (error) { } catch (error) {
newState.error = error return { data: null, error }
return newState
} }
} }
async function logoutService() { async function logoutService() {
const newState = { ...initialState };
try { try {
const response = await client({ method: 'POST', url: LOGIN_URI}) const data = await logout()
newState.data = response.data return { data, error: null }
newState.error = null
return newState
} catch (error) { } catch (error) {
newState.error = error return { data: null, error }
return newState
} }
} }

View File

@ -1,20 +1,62 @@
import axios, { AxiosPromise, AxiosRequestConfig } from "axios";
import { BASE_URL } from '../constants/api' import { BASE_URL } from '../constants/api'
export const client = (props: AxiosRequestConfig): AxiosPromise => axios({ interface FetchConfig extends RequestInit {
method: props.method, url: string;
baseURL: `${BASE_URL}`, data?: any;
url: props.url, withCredentials?: boolean;
headers: props.headers, }
data: props.data,
...props
})
// export const authClient = (props: AxiosRequestConfig) => axios({ export async function client<T = any>(config: FetchConfig): Promise<{ data: T; status: number; request: { status: number } }> {
// method: props.method, const { url, data, withCredentials, headers, ...rest } = config;
// baseURL: `${BASE_URL}`,
// url: props.url, const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`;
// headers: {
// 'Authorization': const fetchOptions: RequestInit = {
// } ...rest,
// }) headers: {
'Content-Type': 'application/json',
...headers,
},
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<string, string>;
delete headersObj['Content-Type'];
fetchOptions.body = data;
} else {
fetchOptions.body = JSON.stringify(data);
}
}
const response = await fetch(fullUrl, fetchOptions);
let responseData: T;
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
responseData = await response.json();
} else {
responseData = await response.text() as any;
}
if (!response.ok) {
const error: any = new Error('HTTP Error');
error.response = {
data: responseData,
status: response.status,
statusText: response.statusText,
};
error.status = response.status;
throw error;
}
return {
data: responseData,
status: response.status,
request: { status: response.status }
};
}

View File

@ -1,34 +1,37 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { GetRequestPagination } from "../types/common" import { GetRequestPagination } from "../types/common"
import { GET_IMAGES_BY_LOCATION_URI } from "../constants/api" import { GET_IMAGES_BY_LOCATION_URI } from "../constants/api"
import { client } from "./config" import { client } from "./config"
import statusCode from "./status-code"
const initialState: any = {
data: null,
error: null
}
interface getImagesReq extends GetRequestPagination { interface getImagesReq extends GetRequestPagination {
location_id?: Number location_id?: number
} }
// API Functions
async function getImagesByLocationService({ page, page_size, location_id }: getImagesReq) { const fetchImagesByLocation = async ({ 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}` const url = `${GET_IMAGES_BY_LOCATION_URI}?location_id=${location_id}&page=${page}&page_size=${page_size}`
const response = await client({ method: 'GET', url })
try { return response.data
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
} }
// React Query Hooks
export const useImagesByLocation = (params: getImagesReq, options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
return useQuery({
queryKey: ['images', 'location', params],
queryFn: () => fetchImagesByLocation(params),
enabled: !!params.location_id,
...options
})
}
// Legacy service functions for backward compatibility
async function getImagesByLocationService({ page, page_size, location_id }: getImagesReq) {
try {
const data = await fetchImagesByLocation({ page, page_size, location_id })
return { data, error: null }
} catch (error) { } catch (error) {
console.log(`GET IMAGE BY LOCATION SERVICE ERROR: ${error}`) console.log(`GET IMAGE BY LOCATION SERVICE ERROR: ${error}`)
return { data: null, error }
} }
} }

View File

@ -1,3 +1,4 @@
import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from "@tanstack/react-query";
import { GetRequestPagination, IHttpResponse } from "../types/common"; import { GetRequestPagination, IHttpResponse } from "../types/common";
import { import {
GET_LIST_LOCATIONS_URI, GET_LIST_LOCATIONS_URI,
@ -9,13 +10,6 @@ import {
POST_CREATE_LOCATION POST_CREATE_LOCATION
} from "../constants/api"; } from "../constants/api";
import { client } from "./config"; import { client } from "./config";
import statusCode from "./status-code";
import { AxiosError } from "axios";
const initialState: any = {
data: null,
error: null
}
interface GetListLocationsArg extends GetRequestPagination { interface GetListLocationsArg extends GetRequestPagination {
order_by?: number, order_by?: number,
@ -27,115 +21,43 @@ interface GetSearchLocations extends GetRequestPagination {
filter?: string filter?: string
} }
async function getListLocationsService({ page, page_size }: GetListLocationsArg) { // API Functions
const newState = { ...initialState }; const fetchListLocations = async ({ page, page_size }: GetListLocationsArg) => {
const url = `${GET_LIST_LOCATIONS_URI}?page=${page}&page_size=${page_size}` const url = `${GET_LIST_LOCATIONS_URI}?page=${page}&page_size=${page_size}`
try { const response = await client({ method: 'GET', url })
const response = await client({ method: 'GET', url: url }) return response.data
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, page: number) { const fetchRecentLocationsRatings = async (page_size: number, page: number) => {
const newState = { ...initialState };
const url = `${GET_LIST_RECENT_LOCATIONS_RATING_URI}?page_size=${page_size}&page=${page}` const url = `${GET_LIST_RECENT_LOCATIONS_RATING_URI}?page_size=${page_size}&page=${page}`
try { const response = await client({ method: 'GET', url })
const response = await client({ method: 'GET', url: url }) return response.data
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 fetchTopLocations = async ({ 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}` 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 })
const response = await client({ method: 'GET', url: url }) return response.data
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 fetchLocation = async (id: number) => {
const newState = { ...initialState };
const url = `${GET_LOCATION_URI}/${id}` const url = `${GET_LOCATION_URI}/${id}`
try { const response = await client({ method: 'GET', url })
const response = await client({ method: 'GET', url: url }) return response.data
switch (response.request.status) {
case statusCode.OK:
newState.data = response.data;
return newState;
default:
newState.error = response.data;
return newState;
}
} catch (error) {
throw(error)
}
} }
async function getLocationTagsService(id: Number) { const fetchLocationTags = async (id: number) => {
const newState = { ...initialState };
const url = `${GET_LOCATION_TAGS_URI}/${id}` const url = `${GET_LOCATION_TAGS_URI}/${id}`
try { const response = await client({ method: 'GET', url })
const response = await client({ method: 'GET', url: url }) return response.data
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 createLocationService(data: FormData): Promise<IHttpResponse> { const createLocation = async (data: FormData) => {
const newState: IHttpResponse = { data: null, error: null}; const response = await client({ method: 'POST', url: POST_CREATE_LOCATION, data, withCredentials: true })
try { return response.data
const response = await client({ method: 'POST', url: POST_CREATE_LOCATION, data: data, withCredentials: true})
newState.data = response.data;
newState.status = response.status
return newState;
} catch (error) {
let err = error as AxiosError;
newState.error = err;
newState.status = err.status;
return newState;
}
} }
async function getSearchLocationService(arg: GetSearchLocations): Promise<IHttpResponse> { const searchLocations = async (arg: GetSearchLocations) => {
const newState: IHttpResponse = { data: null, error: null};
try {
const filter = arg.filter ? arg.filter : '' const filter = arg.filter ? arg.filter : ''
const pageSize = arg.page_size ? arg.page_size : 12 const pageSize = arg.page_size ? arg.page_size : 12
const page = arg.page ? arg.page : 1 const page = arg.page ? arg.page : 1
@ -143,15 +65,137 @@ async function getSearchLocationService(arg: GetSearchLocations): Promise<IHttpR
method: 'GET', method: 'GET',
url: `${GET_SEARCH_LOCATIONS_URI}?name=${arg.name}&filter${filter}&limit=${pageSize}&offset=${page}` url: `${GET_SEARCH_LOCATIONS_URI}?name=${arg.name}&filter${filter}&limit=${pageSize}&offset=${page}`
}) })
return response.data
}
newState.data = response.data; // React Query Hooks
newState.status = response.status; export const useListLocations = (params: GetListLocationsArg, options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
return newState; return useQuery({
queryKey: ['locations', params],
queryFn: () => fetchListLocations(params),
...options
})
}
export const useRecentLocationsRatings = (page_size: number, page: number, options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
return useQuery({
queryKey: ['locations', 'recent-ratings', page_size, page],
queryFn: () => fetchRecentLocationsRatings(page_size, page),
...options
})
}
export const useTopLocations = (params: GetListLocationsArg, options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
return useQuery({
queryKey: ['locations', 'top', params],
queryFn: () => fetchTopLocations(params),
...options
})
}
export const useLocation = (id: number, options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
return useQuery({
queryKey: ['location', id],
queryFn: () => fetchLocation(id),
enabled: !!id,
...options
})
}
export const useLocationTags = (id: number, options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
return useQuery({
queryKey: ['location', 'tags', id],
queryFn: () => fetchLocationTags(id),
enabled: !!id,
...options
})
}
export const useSearchLocations = (params: GetSearchLocations, options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
return useQuery({
queryKey: ['locations', 'search', params],
queryFn: () => searchLocations(params),
enabled: !!params.name,
...options
})
}
export const useCreateLocation = (options?: UseMutationOptions<any, Error, FormData>) => {
return useMutation({
mutationFn: createLocation,
...options
})
}
// Legacy service functions for backward compatibility
async function getListLocationsService({ page, page_size }: GetListLocationsArg) {
try {
const data = await fetchListLocations({ page, page_size })
return { data, error: null }
} catch (error) { } catch (error) {
const err = error as AxiosError; return { data: null, error }
newState.error = err; }
newState.status = err.status; }
return newState;
async function getListRecentLocationsRatingsService(page_size: number, page: number) {
try {
const data = await fetchRecentLocationsRatings(page_size, page)
return { data, error: null }
} catch (error) {
return { data: null, error }
}
}
async function getListTopLocationsService(params: GetListLocationsArg) {
try {
const data = await fetchTopLocations(params)
return { data, error: null }
} catch (error) {
return { data: null, error }
}
}
async function getLocationService(id: number) {
try {
const data = await fetchLocation(id)
return { data, error: null }
} catch (error) {
throw error
}
}
async function getLocationTagsService(id: number) {
try {
const data = await fetchLocationTags(id)
return { data, error: null }
} catch (error) {
return { data: null, error }
}
}
async function createLocationService(data: FormData): Promise<IHttpResponse> {
try {
const responseData = await createLocation(data)
return { data: responseData, error: null }
} catch (error: any) {
return {
data: null,
error,
status: error.status
}
}
}
async function getSearchLocationService(arg: GetSearchLocations): Promise<IHttpResponse> {
try {
const data = await searchLocations(arg)
return { data, error: null }
} catch(error: any) {
return {
data: null,
error,
status: error.status
}
} }
} }

View File

@ -1,4 +1,4 @@
import { AxiosError } from "axios"; import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from "@tanstack/react-query";
import { GetRequestPagination, IHttpResponse } from "..//types/common"; import { GetRequestPagination, IHttpResponse } from "..//types/common";
import { client } from "./config"; import { client } from "./config";
import { GET_NEWS_EVENTS_URI, POST_NEWS_EVENTS_URI } from "../../src/constants/api"; import { GET_NEWS_EVENTS_URI, POST_NEWS_EVENTS_URI } from "../../src/constants/api";
@ -7,21 +7,6 @@ interface GetNewsSevice extends GetRequestPagination {
is_with_approval: number is_with_approval: number
} }
async function getNewsServices({ page, page_size, is_with_approval}: GetNewsSevice): Promise<IHttpResponse> {
const newState: IHttpResponse = { data: null, error: null};
try {
const response = await client({ method: 'GET', url: `${GET_NEWS_EVENTS_URI}?page=${page}&page_size=${page_size}&is_with_approval=${is_with_approval}`});
newState.data = response.data;
newState.status = response.status;
return newState;
} catch (error) {
let err = error as AxiosError;
newState.error = err;
newState.status = err.status
throw(newState)
}
}
interface PostNewsServiceBody { interface PostNewsServiceBody {
title: string, title: string,
url: string, url: string,
@ -29,18 +14,52 @@ interface PostNewsServiceBody {
submitted_by: number submitted_by: number
} }
async function postNewsService(req: PostNewsServiceBody): Promise<IHttpResponse> { // API Functions
const newState: IHttpResponse = { data: null, error: null} const fetchNews = async ({ page, page_size, is_with_approval }: GetNewsSevice) => {
try { const response = await client({
method: 'GET',
url: `${GET_NEWS_EVENTS_URI}?page=${page}&page_size=${page_size}&is_with_approval=${is_with_approval}`
})
return response.data
}
const postNews = async (req: PostNewsServiceBody) => {
const response = await client({ method: 'POST', url: POST_NEWS_EVENTS_URI, data: req, withCredentials: true }) const response = await client({ method: 'POST', url: POST_NEWS_EVENTS_URI, data: req, withCredentials: true })
newState.data = response.data return response.data
newState.status = response.status }
return newState
} catch (error) { // React Query Hooks
let err = error as AxiosError; export const useNews = (params: GetNewsSevice, options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
newState.error = err; return useQuery({
newState.status = err.status queryKey: ['news', params],
throw(newState) queryFn: () => fetchNews(params),
...options
})
}
export const usePostNews = (options?: UseMutationOptions<any, Error, PostNewsServiceBody>) => {
return useMutation({
mutationFn: postNews,
...options
})
}
// Legacy service functions for backward compatibility
async function getNewsServices({ page, page_size, is_with_approval}: GetNewsSevice): Promise<IHttpResponse> {
try {
const data = await fetchNews({ page, page_size, is_with_approval })
return { data, error: null }
} catch (error: any) {
throw { data: null, error, status: error.status }
}
}
async function postNewsService(req: PostNewsServiceBody): Promise<IHttpResponse> {
try {
const data = await postNews(req)
return { data, error: null }
} catch (error: any) {
throw { data: null, error, status: error.status }
} }
} }

View File

@ -1,41 +1,74 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { client } from "./config"; import { client } from "./config";
import { GET_PROVINCES, GET_REGENCIES, GET_REGIONS } from "../constants/api"; import { GET_PROVINCES, GET_REGENCIES, GET_REGIONS } from "../constants/api";
import { IHttpResponse } from "src/types/common"; import { IHttpResponse } from "src/types/common";
async function getRegionsService(): Promise<IHttpResponse> { // API Functions
const newState: IHttpResponse = {data: null, error: null} const fetchRegions = async () => {
try {
const response = await client({ method: 'GET', url: GET_REGIONS }) const response = await client({ method: 'GET', url: GET_REGIONS })
newState.data = response.data; return response.data
return newState }
const fetchProvinces = async () => {
const response = await client({ method: 'GET', url: GET_PROVINCES })
return response.data
}
const fetchRegencies = async () => {
const response = await client({ method: 'GET', url: GET_REGENCIES })
return response.data
}
// React Query Hooks
export const useRegions = (options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
return useQuery({
queryKey: ['regions'],
queryFn: fetchRegions,
...options
})
}
export const useProvinces = (options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
return useQuery({
queryKey: ['provinces'],
queryFn: fetchProvinces,
...options
})
}
export const useRegencies = (options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
return useQuery({
queryKey: ['regencies'],
queryFn: fetchRegencies,
...options
})
}
// Legacy service functions for backward compatibility
async function getRegionsService(): Promise<IHttpResponse> {
try {
const data = await fetchRegions()
return { data, error: null }
} catch(err) { } catch(err) {
newState.error = err throw { data: null, error: err }
throw (newState)
} }
} }
async function getProvincesService(): Promise<IHttpResponse> { async function getProvincesService(): Promise<IHttpResponse> {
const newState: IHttpResponse = { data: null, error: null}
try { try {
const response = await client({ method: 'GET', url: GET_PROVINCES}) const data = await fetchProvinces()
newState.data = response.data; return { data, error: null }
return newState
} catch(err) { } catch(err) {
newState.error = err throw { data: null, error: err }
throw (newState)
} }
} }
async function getRegenciesService(): Promise<IHttpResponse> { async function getRegenciesService(): Promise<IHttpResponse> {
const newState: IHttpResponse = { data: null, error: null};
try { try {
const response = await client({ method: 'GET', url: GET_REGENCIES }) const response = await client({ method: 'GET', url: GET_REGENCIES })
newState.data = response.data; return { data: response.data, error: null, status: response.status }
newState.status = response.status
return newState
} catch(err) { } catch(err) {
newState.error = err throw { data: null, error: err }
throw (newState)
} }
} }

View File

@ -1,14 +1,8 @@
import { AxiosError } from "axios" import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from "@tanstack/react-query";
import { client } from "./config"; 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_LOCATION_URI } from "../constants/api";
import { IHttpResponse } from "src/types/common"; import { IHttpResponse } from "src/types/common";
const initialState: IHttpResponse = {
data: null,
error: AxiosError,
status: 0,
}
interface postReviewLocationReq { interface postReviewLocationReq {
submitted_by: number, submitted_by: number,
comments: string, comments: string,
@ -18,31 +12,54 @@ interface postReviewLocationReq {
location_id: number location_id: number
} }
async function postReviewLocation(req: postReviewLocationReq) { // API Functions
const newState = { ...initialState }; const postReview = async (req: postReviewLocationReq) => {
try {
const response = await client({ method: 'POST', url: POST_REVIEW_LOCATION_URI, data: req, withCredentials: true }) const response = await client({ method: 'POST', url: POST_REVIEW_LOCATION_URI, data: req, withCredentials: true })
newState.data = response.data return response.data
newState.error = null }
return newState
const fetchCurrentUserLocationReview = async (location_id: number) => {
const response = await client({
method: 'GET',
url: `${GET_CURRENT_USER_REVIEW_LOCATION_URI}/${location_id}`,
withCredentials: true
})
return response.data
}
// React Query Hooks
export const usePostReview = (options?: UseMutationOptions<any, Error, postReviewLocationReq>) => {
return useMutation({
mutationFn: postReview,
...options
})
}
export const useCurrentUserLocationReview = (location_id: number, options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
return useQuery({
queryKey: ['user', 'review', location_id],
queryFn: () => fetchCurrentUserLocationReview(location_id),
enabled: !!location_id,
...options
})
}
// Legacy service functions for backward compatibility
async function postReviewLocation(req: postReviewLocationReq) {
try {
const data = await postReview(req)
return { data, error: null }
} catch (error) { } catch (error) {
newState.error = error throw error
throw(error)
} }
} }
async function getCurrentUserLocationReviewService(location_id: number): Promise<IHttpResponse> { async function getCurrentUserLocationReviewService(location_id: number): Promise<IHttpResponse> {
const newState = { ...initialState };
try { try {
const response = await client({ method: 'GET', url: `${GET_CURRENT_USER_REVIEW_LOCATION_URI}/${location_id}`, withCredentials: true}) const data = await fetchCurrentUserLocationReview(location_id)
newState.data = response.data return { data, error: null }
newState.error = null } catch (error: any) {
return newState throw { data: null, error, status: error.status }
} catch (err) {
let error = err as AxiosError;
newState.error = error
newState.status = error.response?.status;
throw(newState)
} }
} }

View File

@ -1,67 +1,94 @@
import { AxiosError } from "axios"; import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from "@tanstack/react-query";
import { DELETE_USER_AVATAR, GET_CURRENT_USER_STATS, PATCH_USER_AVATAR, PATCH_USER_INFO } from "../constants/api"; import { DELETE_USER_AVATAR, GET_CURRENT_USER_STATS, PATCH_USER_AVATAR, PATCH_USER_INFO } from "../constants/api";
import { IHttpResponse } from "../types/common"; import { IHttpResponse } from "../types/common";
import { client } from "./config"; import { client } from "./config";
import { UserInfo } from "../../src/domains/User"; import { UserInfo } from "../../src/domains/User";
// API Functions
async function getUserStatsService(): Promise<IHttpResponse> { const fetchUserStats = async () => {
const newState: IHttpResponse = { data: null, error: null };
try {
const res = await client({ method: 'GET', url: GET_CURRENT_USER_STATS, withCredentials: true }) const res = await client({ method: 'GET', url: GET_CURRENT_USER_STATS, withCredentials: true })
newState.data = res.data return res.data
newState.status = res.status }
return newState
} catch(error) { const patchUserAvatar = async (form: FormData) => {
let err = error as AxiosError const res = await client({ method: "PATCH", url: PATCH_USER_AVATAR, data: form, withCredentials: true })
newState.error = err return res.data
newState.status = err.status }
throw(newState)
const patchUserInfo = async (data: UserInfo) => {
const res = await client({ method: 'PATCH', url: PATCH_USER_INFO, data, withCredentials: true })
return res.data
}
const deleteUserAvatar = async () => {
const res = await client({ method: 'DELETE', url: DELETE_USER_AVATAR, withCredentials: true })
return res.data
}
// React Query Hooks
export const useUserStats = (options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
return useQuery({
queryKey: ['user', 'stats'],
queryFn: fetchUserStats,
...options
})
}
export const usePatchUserAvatar = (options?: UseMutationOptions<any, Error, FormData>) => {
return useMutation({
mutationFn: patchUserAvatar,
...options
})
}
export const usePatchUserInfo = (options?: UseMutationOptions<any, Error, UserInfo>) => {
return useMutation({
mutationFn: patchUserInfo,
...options
})
}
export const useDeleteUserAvatar = (options?: UseMutationOptions<any, Error, void>) => {
return useMutation({
mutationFn: deleteUserAvatar,
...options
})
}
// Legacy service functions for backward compatibility
async function getUserStatsService(): Promise<IHttpResponse> {
try {
const data = await fetchUserStats()
return { data, error: null }
} catch(error: any) {
throw { data: null, error, status: error.status }
} }
} }
async function patchUserAvatarService(form: FormData): Promise<IHttpResponse> { async function patchUserAvatarService(form: FormData): Promise<IHttpResponse> {
const newState: IHttpResponse = { data: null, error: null};
try { try {
const res = await client({ method: "PATCH", url: PATCH_USER_AVATAR, data: form, withCredentials: true}) const data = await patchUserAvatar(form)
newState.data = res.data; return { data, error: null }
newState.status = res.status; } catch(error: any) {
return newState; throw { data: null, error, status: error.status }
} catch(error) {
let err = error as AxiosError;
newState.error = err
newState.status = err.status
throw(newState);
} }
} }
async function patchUserInfoService(data: UserInfo): Promise<IHttpResponse> { async function patchUserInfoService(data: UserInfo): Promise<IHttpResponse> {
const newState: IHttpResponse = { data: null, error: null};
try { try {
const res = await client({ method: 'PATCH', url: PATCH_USER_INFO, data: data, withCredentials: true}) const responseData = await patchUserInfo(data)
newState.data = res.data; return { data: responseData, error: null }
newState.status = res.status; } catch(error: any) {
return newState; throw { data: null, error, status: error.status }
} catch(error) {
let err = error as AxiosError;
newState.error = err;
newState.status = err.status;
throw(newState);
} }
} }
async function deleteUserAvatarService(): Promise<IHttpResponse> { async function deleteUserAvatarService(): Promise<IHttpResponse> {
const newState: IHttpResponse = { data: null, error: null};
try { try {
const res = await client({ method: 'DELETE', url: DELETE_USER_AVATAR, withCredentials: true}) const data = await deleteUserAvatar()
newState.data = res.data; return { data, error: null }
newState.status = res.status } catch (error: any) {
return newState throw { data: null, error, status: error.status }
} catch (error) {
let err = error as AxiosError;
newState.error = err;
newState.status = err.status;
throw(newState);
} }
} }

View File

@ -1,4 +1,6 @@
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function handleAxiosError(error: AxiosError) { export function handleAxiosError(error: AxiosError) {
return error.response?.data return error.response?.data
@ -12,3 +14,7 @@ export function isUrl(val: string): boolean {
var urlPattern = /^https:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/; var urlPattern = /^https:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
return urlPattern.test(val); return urlPattern.test(val);
} }
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

18
tests/example.spec.ts Normal file
View File

@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Click the get started link.
await page.getByRole('link', { name: 'Get started' }).click();
// Expects page to have a heading with the name of Installation.
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});