Save changes before submodule conversion
This commit is contained in:
parent
c51f187793
commit
6b4d10e8a0
@ -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;
|
||||||
|
|||||||
96
src/app.tsx
96
src/app.tsx
@ -9,59 +9,71 @@ 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 (
|
||||||
<Provider store={store}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<PersistGate persistor={persistore}>
|
<Provider store={store}>
|
||||||
<Router>
|
<PersistGate persistor={persistore}>
|
||||||
<Routes>
|
<Router>
|
||||||
<Route path='/login' element={<Login />} />
|
<Routes>
|
||||||
<Route element={<DefaultLayout />}>
|
<Route path='/login' element={<Login />} />
|
||||||
{routes.map(({ path, name, element, protectedRoute }) => {
|
<Route element={<DefaultLayout />}>
|
||||||
let Element = element as any
|
{routes.map(({ path, name, element, protectedRoute }) => {
|
||||||
if (protectedRoute === "user") {
|
let Element = element as any
|
||||||
return (
|
if (protectedRoute === "user") {
|
||||||
<Route
|
return (
|
||||||
path={path}
|
<Route
|
||||||
id={name}
|
path={path}
|
||||||
element={
|
id={name}
|
||||||
<UserProtectedRoute>
|
element={
|
||||||
<Element />
|
<UserProtectedRoute>
|
||||||
</UserProtectedRoute>
|
<Element />
|
||||||
}
|
</UserProtectedRoute>
|
||||||
/>
|
}
|
||||||
)
|
/>
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (protectedRoute === "admin") {
|
if (protectedRoute === "admin") {
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
path={path}
|
||||||
|
id={name}
|
||||||
|
element={
|
||||||
|
<AdminProtectedRoute>
|
||||||
|
<Element />
|
||||||
|
</AdminProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Route
|
<Route
|
||||||
path={path}
|
path={path}
|
||||||
id={name}
|
id={name}
|
||||||
element={
|
element={element}
|
||||||
<AdminProtectedRoute>
|
|
||||||
<Element />
|
|
||||||
</AdminProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
})}
|
||||||
return (
|
<Route path="*" element={<NotFound />} />
|
||||||
<Route
|
</Route>
|
||||||
path={path}
|
</Routes>
|
||||||
id={name}
|
</Router>
|
||||||
element={element}
|
</PersistGate>
|
||||||
/>
|
</Provider>
|
||||||
)
|
</QueryClientProvider>
|
||||||
})}
|
|
||||||
<Route path="*" element={<NotFound />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</Router>
|
|
||||||
</PersistGate>
|
|
||||||
</Provider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
src/assets/Screenshot 2026-01-28 at 19.22.45.png
Normal file
BIN
src/assets/Screenshot 2026-01-28 at 19.22.45.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
160
src/components/Card/RatingsCard/index.tsx
Normal file
160
src/components/Card/RatingsCard/index.tsx
Normal 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;
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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 []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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`;
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
)}
|
||||||
|
</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>
|
</div>
|
||||||
{locationDetail.detail.critic_count !== 0 &&
|
|
||||||
<div className={'ml-14 text-sm'}>
|
|
||||||
Based on {locationDetail.detail.critic_count} reviews
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</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="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={'text-4xl text-center mt-2 mr-4'} style={{ width: 95, float: 'left' }}>
|
<div className="pb-1 border-b border-[#38444d]">
|
||||||
{locationDetail.detail.user_count !== 0 ? Math.floor(Number(locationDetail.detail.user_score) / Number(locationDetail.detail.user_count)) : "NR"}
|
<h2 className="inline-block font-bold text-md tracking-[0.9px]">DETAILS</h2>
|
||||||
<div className={"items-center p-2"}>
|
<div className="float-right text-[12px] tracking-[0.9px]">
|
||||||
<div className={'mr-3 users-score-bar'}>
|
<a class="group-hover:opacity-100 transition-opacity duration-200 underline underline-offset-4" href="#">SUBMIT CORRECTION</a>
|
||||||
<div className={"mt-1"} style={{ height: 4, width: 80, backgroundColor: "#72767d" }}>
|
</div>
|
||||||
<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 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>
|
||||||
|
))}
|
||||||
|
<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>
|
||||||
{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>
|
||||||
</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,90 +446,95 @@ 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} />
|
||||||
<a>Review Guidelines</a>
|
<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>
|
||||||
|
</div>
|
||||||
|
{pageState.on_submit_loading ?
|
||||||
|
<SpinnerLoading />
|
||||||
|
:
|
||||||
|
<span className={`text-xxs p-1 bg-gray tracking-[1px] ${!pageState.enable_post ? 'hidden' : ''}`}>
|
||||||
|
<a href={'#'} onClick={handleSubmitReview}>
|
||||||
|
POST
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
{pageState.on_submit_loading ?
|
|
||||||
<SpinnerLoading />
|
|
||||||
:
|
|
||||||
<span className={'text-xxs p-1 text-area-button'} style={pageState.enable_post ? '' : { display: 'none' }}>
|
|
||||||
<a href={'#'} onClick={handleSubmitReview}>
|
|
||||||
POST
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</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>
|
||||||
|
|||||||
@ -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>,
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API Functions
|
||||||
|
const createAccount = async ({ username, password }: IAuthentication) => {
|
||||||
|
const response = await client({ method: 'POST', url: SIGNUP_URI, data: { username, password }, withCredentials: true })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
async function createAccountService({ username, password }: IAuthentication) {
|
||||||
const newState = { ...initialState };
|
|
||||||
try {
|
try {
|
||||||
const response = await client({ method: 'POST', url: SIGNUP_URI, data: { username, password }, withCredentials: true })
|
const data = await createAccount({ 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 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 }
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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 })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
const response = await client({ method: 'GET', url: url})
|
const data = await fetchImagesByLocation({ page, page_size, location_id })
|
||||||
switch (response.request.status) {
|
return { data, error: null }
|
||||||
case statusCode.OK:
|
|
||||||
newState.data = response.data;
|
|
||||||
return newState
|
|
||||||
default:
|
|
||||||
newState.error = response.data;
|
|
||||||
return newState
|
|
||||||
}
|
|
||||||
} 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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,131 +21,181 @@ 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}`
|
||||||
|
const response = await client({ method: 'GET', url })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRecentLocationsRatings = async (page_size: number, page: number) => {
|
||||||
|
const url = `${GET_LIST_RECENT_LOCATIONS_RATING_URI}?page_size=${page_size}&page=${page}`
|
||||||
|
const response = await client({ method: 'GET', url })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTopLocations = async ({ page, page_size, order_by, region_type }: GetListLocationsArg) => {
|
||||||
|
const url = `${GET_LIST_TOP_LOCATIONS}?page=${page}&page_size=${page_size}&order_by=${order_by}®ion_type=${region_type}`
|
||||||
|
const response = await client({ method: 'GET', url })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchLocation = async (id: number) => {
|
||||||
|
const url = `${GET_LOCATION_URI}/${id}`
|
||||||
|
const response = await client({ method: 'GET', url })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchLocationTags = async (id: number) => {
|
||||||
|
const url = `${GET_LOCATION_TAGS_URI}/${id}`
|
||||||
|
const response = await client({ method: 'GET', url })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const createLocation = async (data: FormData) => {
|
||||||
|
const response = await client({ method: 'POST', url: POST_CREATE_LOCATION, data, withCredentials: true })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchLocations = async (arg: GetSearchLocations) => {
|
||||||
|
const filter = arg.filter ? arg.filter : ''
|
||||||
|
const pageSize = arg.page_size ? arg.page_size : 12
|
||||||
|
const page = arg.page ? arg.page : 1
|
||||||
|
const response = await client({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${GET_SEARCH_LOCATIONS_URI}?name=${arg.name}&filter${filter}&limit=${pageSize}&offset=${page}`
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// React Query Hooks
|
||||||
|
export const useListLocations = (params: GetListLocationsArg, options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
|
||||||
|
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 {
|
try {
|
||||||
const response = await client({ method: 'GET', url: url })
|
const data = await fetchListLocations({ page, page_size })
|
||||||
switch (response.request.status) {
|
return { data, error: null }
|
||||||
case statusCode.OK:
|
|
||||||
newState.data = response.data;
|
|
||||||
return newState;
|
|
||||||
default:
|
|
||||||
newState.error = response.data;
|
|
||||||
return newState
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
return { data: null, error }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getListRecentLocationsRatingsService(page_size: number, page: number) {
|
async function getListRecentLocationsRatingsService(page_size: number, page: number) {
|
||||||
const newState = { ...initialState };
|
|
||||||
const url = `${GET_LIST_RECENT_LOCATIONS_RATING_URI}?page_size=${page_size}&page=${page}`
|
|
||||||
try {
|
try {
|
||||||
const response = await client({ method: 'GET', url: url })
|
const data = await fetchRecentLocationsRatings(page_size, page)
|
||||||
switch (response.request.status) {
|
return { data, error: null }
|
||||||
case statusCode.OK:
|
|
||||||
newState.data = response.data;
|
|
||||||
return newState;
|
|
||||||
default:
|
|
||||||
newState.error = response.data;
|
|
||||||
return newState
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
return { data: null, error }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getListTopLocationsService({ page, page_size, order_by, region_type }: GetListLocationsArg) {
|
async function getListTopLocationsService(params: GetListLocationsArg) {
|
||||||
const newState = { ...initialState };
|
|
||||||
const url = `${GET_LIST_TOP_LOCATIONS}?page=${page}&page_size=${page_size}&order_by=${order_by}®ion_type=${region_type}`
|
|
||||||
try {
|
try {
|
||||||
const response = await client({ method: 'GET', url: url })
|
const data = await fetchTopLocations(params)
|
||||||
switch (response.request.status) {
|
return { data, error: null }
|
||||||
case statusCode.OK:
|
|
||||||
newState.data = response.data;
|
|
||||||
return newState;
|
|
||||||
default:
|
|
||||||
newState.error = response.data;
|
|
||||||
return newState
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
return { data: null, error }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLocationService(id: Number) {
|
async function getLocationService(id: number) {
|
||||||
const newState = { ...initialState };
|
|
||||||
const url = `${GET_LOCATION_URI}/${id}`
|
|
||||||
try {
|
try {
|
||||||
const response = await client({ method: 'GET', url: url })
|
const data = await fetchLocation(id)
|
||||||
switch (response.request.status) {
|
return { data, error: null }
|
||||||
case statusCode.OK:
|
|
||||||
newState.data = response.data;
|
|
||||||
return newState;
|
|
||||||
default:
|
|
||||||
newState.error = response.data;
|
|
||||||
return newState;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw(error)
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLocationTagsService(id: Number) {
|
async function getLocationTagsService(id: number) {
|
||||||
const newState = { ...initialState };
|
|
||||||
const url = `${GET_LOCATION_TAGS_URI}/${id}`
|
|
||||||
try {
|
try {
|
||||||
const response = await client({ method: 'GET', url: url })
|
const data = await fetchLocationTags(id)
|
||||||
switch (response.request.status) {
|
return { data, error: null }
|
||||||
case statusCode.OK:
|
|
||||||
newState.data = response.data;
|
|
||||||
return newState;
|
|
||||||
default:
|
|
||||||
newState.error = response.data;
|
|
||||||
return newState;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
return { data: null, error }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createLocationService(data: FormData): Promise<IHttpResponse> {
|
async function createLocationService(data: FormData): Promise<IHttpResponse> {
|
||||||
const newState: IHttpResponse = { data: null, error: null};
|
|
||||||
try {
|
try {
|
||||||
const response = await client({ method: 'POST', url: POST_CREATE_LOCATION, data: data, withCredentials: true})
|
const responseData = await createLocation(data)
|
||||||
newState.data = response.data;
|
return { data: responseData, error: null }
|
||||||
newState.status = response.status
|
} catch (error: any) {
|
||||||
return newState;
|
return {
|
||||||
} catch (error) {
|
data: null,
|
||||||
let err = error as AxiosError;
|
error,
|
||||||
newState.error = err;
|
status: error.status
|
||||||
newState.status = err.status;
|
}
|
||||||
return newState;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSearchLocationService(arg: GetSearchLocations): Promise<IHttpResponse> {
|
async function getSearchLocationService(arg: GetSearchLocations): Promise<IHttpResponse> {
|
||||||
const newState: IHttpResponse = { data: null, error: null};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filter = arg.filter ? arg.filter : ''
|
const data = await searchLocations(arg)
|
||||||
const pageSize= arg.page_size ? arg.page_size : 12
|
return { data, error: null }
|
||||||
const page = arg.page ? arg.page : 1
|
} catch(error: any) {
|
||||||
const response = await client({
|
return {
|
||||||
method: 'GET',
|
data: null,
|
||||||
url: `${GET_SEARCH_LOCATIONS_URI}?name=${arg.name}&filter${filter}&limit=${pageSize}&offset=${page}`
|
error,
|
||||||
})
|
status: error.status
|
||||||
|
}
|
||||||
newState.data = response.data;
|
|
||||||
newState.status = response.status;
|
|
||||||
return newState;
|
|
||||||
} catch(error) {
|
|
||||||
const err = error as AxiosError;
|
|
||||||
newState.error = err;
|
|
||||||
newState.status = err.status;
|
|
||||||
return newState;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
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 })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// React Query Hooks
|
||||||
|
export const useNews = (params: GetNewsSevice, options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['news', params],
|
||||||
|
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 {
|
try {
|
||||||
const response = await client({ method: 'POST', url: POST_NEWS_EVENTS_URI, data: req, withCredentials: true})
|
const data = await fetchNews({ page, page_size, is_with_approval })
|
||||||
newState.data = response.data
|
return { data, error: null }
|
||||||
newState.status = response.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
|
async function postNewsService(req: PostNewsServiceBody): Promise<IHttpResponse> {
|
||||||
throw(newState)
|
try {
|
||||||
|
const data = await postNews(req)
|
||||||
|
return { data, error: null }
|
||||||
|
} catch (error: any) {
|
||||||
|
throw { data: null, error, status: error.status }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
|
// API Functions
|
||||||
|
const fetchRegions = async () => {
|
||||||
|
const response = await client({ method: 'GET', url: GET_REGIONS })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
async function getRegionsService(): Promise<IHttpResponse> {
|
||||||
const newState: IHttpResponse = {data: null, error: null}
|
|
||||||
try {
|
try {
|
||||||
const response = await client({ method: 'GET', url: GET_REGIONS})
|
const data = await fetchRegions()
|
||||||
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 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API Functions
|
||||||
|
const postReview = async (req: postReviewLocationReq) => {
|
||||||
|
const response = await client({ method: 'POST', url: POST_REVIEW_LOCATION_URI, data: req, withCredentials: true })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
async function postReviewLocation(req: postReviewLocationReq) {
|
||||||
const newState = { ...initialState };
|
|
||||||
try {
|
try {
|
||||||
const response = await client({ method: 'POST', url: POST_REVIEW_LOCATION_URI, data: req, withCredentials: true})
|
const data = await postReview(req)
|
||||||
newState.data = response.data
|
return { data, error: null }
|
||||||
newState.error = null
|
|
||||||
return newState
|
|
||||||
} 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
const fetchUserStats = async () => {
|
||||||
|
const res = await client({ method: 'GET', url: GET_CURRENT_USER_STATS, withCredentials: true })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchUserAvatar = async (form: FormData) => {
|
||||||
|
const res = await client({ method: "PATCH", url: PATCH_USER_AVATAR, data: form, withCredentials: true })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
async function getUserStatsService(): Promise<IHttpResponse> {
|
||||||
const newState: IHttpResponse = { data: null, error: null };
|
|
||||||
try {
|
try {
|
||||||
const res = await client({ method: 'GET', url: GET_CURRENT_USER_STATS, withCredentials: true})
|
const data = await fetchUserStats()
|
||||||
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 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
18
tests/example.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user