diff --git a/src/app.css b/src/app.css index 81f0dd6..ff92a93 100755 --- a/src/app.css +++ b/src/app.css @@ -15,7 +15,7 @@ overflow: auto; outline: none; box-shadow: none; - background-color: #40444b; + background-color: #202225; width: 100%; min-height: 100px; overflow-y: hidden; diff --git a/src/app.tsx b/src/app.tsx index c05cba2..4cfddb3 100755 --- a/src/app.tsx +++ b/src/app.tsx @@ -9,59 +9,71 @@ import { persistore, store } from './store/config' import { PersistGate } from 'redux-persist/integration/react' import { AdminProtectedRoute, UserProtectedRoute } from './routes/ProtectedRoute' 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() { const { routes } = getRoutes(); return ( - - - - - } /> - }> - {routes.map(({ path, name, element, protectedRoute }) => { - let Element = element as any - if (protectedRoute === "user") { - return ( - - - - } - /> - ) - } + + + + + + } /> + }> + {routes.map(({ path, name, element, protectedRoute }) => { + let Element = element as any + if (protectedRoute === "user") { + return ( + + + + } + /> + ) + } - if (protectedRoute === "admin") { + if (protectedRoute === "admin") { + return ( + + + + } + /> + ) + } return ( - - - } + element={element} /> ) - } - return ( - - ) - })} - } /> - - - - - + })} + } /> + + + + + + ) } diff --git a/src/assets/Screenshot 2026-01-28 at 19.22.45.png b/src/assets/Screenshot 2026-01-28 at 19.22.45.png new file mode 100644 index 0000000..4a92041 Binary files /dev/null and b/src/assets/Screenshot 2026-01-28 at 19.22.45.png differ diff --git a/src/components/Card/RatingsCard/index.tsx b/src/components/Card/RatingsCard/index.tsx new file mode 100644 index 0000000..722e392 --- /dev/null +++ b/src/components/Card/RatingsCard/index.tsx @@ -0,0 +1,160 @@ +interface RatingData { + score: number; + count: number; +} + +interface DetailRatings { + environment: number; + cleanliness: number; + price: number; + facility: number; +} + +interface RatingsCardProps { + data: T; + getCriticData: (data: T) => RatingData; + getUserData: (data: T) => RatingData; + getCriticDetails?: (data: T) => DetailRatings; + getUserDetails?: (data: T) => DetailRatings; +} + +const RatingsCard = ({ + data, + getCriticData, + getUserData, + getCriticDetails, + getUserDetails +}: RatingsCardProps) => { + 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 ( +
+ {/* Critics Score Section */} +
+
+
CRITICS SCORE
+
+ {calculateScore(criticData.score, criticData.count)} +
+
+
+
+ {criticData.count !== 0 && ( +
+ Based on {formatCount(criticData.count)} reviews +
+ )} +
+ + {/* Critics Detail Cards */} + {criticDetails && ( +
+
+
ENVIRONMENT
+
+
{criticDetails.environment}
+
+
+
+
+
+ +
+
PRICE
+
+
{criticDetails.price}
+
+
+
+
+
+ +
+
FACILITY
+
+
{criticDetails.facility}
+
+
+
+
+
+
+ )} +
+ + {/* Users Score Section */} +
+
+
USERS SCORE
+
+ {calculateScore(userData.score, userData.count)} +
+
+
+
+ {userData.count !== 0 && ( +
+ Based on {formatCount(userData.count)} reviews +
+ )} +
+ + {/* Users Detail Cards */} + {userDetails && ( +
+
+
ENVIRONMENT
+
+
{userDetails.environment}
+
+
+
+
+
+ +
+
PRICE
+
+
{userDetails.price}
+
+
+
+
+
+ +
+
FACILITY
+
+
{userDetails.facility}
+
+
+
+
+
+
+ )} +
+
+ ); +}; + +export default RatingsCard; diff --git a/src/components/CustomInterweave/index.tsx b/src/components/CustomInterweave/index.tsx index c4d9a24..fe7f18e 100755 --- a/src/components/CustomInterweave/index.tsx +++ b/src/components/CustomInterweave/index.tsx @@ -1,8 +1,64 @@ 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 { EmojiMatcher, PathConfig } from 'interweave-emoji'; +class SevenTVMatcher extends Matcher { + private emotes: Record = { + '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 ( + {emoteName} + ); + } + + 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 globalMatchers: MatcherInterface[] = [ @@ -15,6 +71,7 @@ const globalMatchers: MatcherInterface[] = [ convertShortcode: true, convertUnicode: true, }), + new SevenTVMatcher('7tv'), ]; function getEmojiPath(hexcode: string, { enlarged }: PathConfig): string { diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index dbafe6e..03ad0f9 100755 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -67,7 +67,7 @@ function Header() { navigate(`/location/${val.value}`) } - const onLoadSelectOptions = async (inputValue: string) => { + const onLoadSelectOptions = async (inputValue: string): Promise => { try { const results = await getSearchLocationService({ name: inputValue, @@ -89,6 +89,7 @@ function Header() { return result } catch (err) { alert(err) + return [] } } diff --git a/src/constants/api.ts b/src/constants/api.ts index ac6856c..ce55a50 100755 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -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 LOGIN_URI = `${BASE_URL}/user/login`; diff --git a/src/pages/Discovery/index.tsx b/src/pages/Discovery/index.tsx index 7e0d3af..2094869 100755 --- a/src/pages/Discovery/index.tsx +++ b/src/pages/Discovery/index.tsx @@ -195,7 +195,7 @@ function Discovery() { ))}
- + + {/*
{news.data.map((x: News) => ( @@ -134,12 +134,12 @@ function Home() { )) }
-
+ */} {/* END RECENT NEWS / EVENT SECTION */} {/* LOCATION CRITICS BEST AND USERS BEST SECTION */} -
+ {/*
{popular_user_review.data.map((x) => ( @@ -166,7 +166,7 @@ function Home() { }
-
+
*/} {/* START LOCATION CRITICS BEST AND USERS BEST SECTION */} @@ -178,7 +178,15 @@ function Home() { {popular.data.map((x) => (
- + { + e.currentTarget.src = 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp'; + e.currentTarget.onerror = null; + }} + />

{x.name}

{x.location}

diff --git a/src/pages/LocationDetail/index.tsx b/src/pages/LocationDetail/index.tsx index eafd49b..98c23d8 100755 --- a/src/pages/LocationDetail/index.tsx +++ b/src/pages/LocationDetail/index.tsx @@ -14,11 +14,12 @@ import { AxiosError } from 'axios'; import { handleAxiosError, useAutosizeTextArea } from '../../utils'; import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation } from "../../services"; import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading } from '../../components'; +import RatingsCard from '../../components/Card/RatingsCard'; import { useSelector } from 'react-redux'; import { UserRootState } from '../../store/type'; import { DEFAULT_AVATAR_IMG } from '../../constants/default'; -import './index.css'; import { IHttpResponse } from '../../types/common'; +import { ImagePlus } from 'lucide-react' import ReactTextareaAutosize from 'react-textarea-autosize'; const SORT_TYPE = [ @@ -47,6 +48,8 @@ function LocationDetail() { score_input: '', }) const [isLoading, setIsLoading] = useState(true) + const [currentIndex, setCurrentIndex] = useState(0); + const currentImage = locationImages?.images[currentIndex]?.src || locationDetail.detail.thumbnail || ""; const navigate = useNavigate(); const user = useSelector((state: UserRootState) => state.auth) @@ -199,151 +202,217 @@ function LocationDetail() { }, [updatePage, id]) return ( -
+
-
-
-
-
+
+
+

{locationDetail?.detail.name}

- {isLoading ? -
+ {/* {isLoading ? +
: -
+
setLightboxOpen(true)} - className={'mt-3'} - style={{ display: 'grid', position: 'relative', gridTemplateColumns: 'repeat(12,1fr)', cursor: 'zoom-in' }} + className="mt-3 grid relative grid-cols-12 cursor-zoom-in" >{Number(locationImages?.total_image) > 0 && -
- +
+ {locationImages?.images.length > 1 && -
+
Total images ({locationImages?.images.length})
}
} {locationImages?.images.length > 1 && -
- +
+
} -
- } -
-
-
CRITICS SCORE
-
- {locationDetail.detail.critic_count !== 0 ? Math.floor(Number(locationDetail.detail.critic_score) / Number(locationDetail.detail.critic_count)) : "NR"} -
-
-
-
+ } */} + + {isLoading ? ( +
+ ) : ( +
+
+ {/* Main image display */} +
+ setLightboxOpen(true)} + /> + + {locationImages?.images.length > 1 && ( + <> + + + + )} + + {/* Total images badge */} + {locationImages?.images.length > 1 && ( +
+ Total images ({locationImages.images.length})
+ )} +
+ + {/* Thumbnail strip */} + {locationImages?.images.length > 1 && ( +
+ {locationImages.images.map((image, index) => ( + setCurrentIndex(index)} + /> + ))}
-
+ )}
- {locationDetail.detail.critic_count !== 0 && -
- Based on {locationDetail.detail.critic_count} reviews -
- }
-
-
USERS SCORE
-
- {locationDetail.detail.user_count !== 0 ? Math.floor(Number(locationDetail.detail.user_score) / Number(locationDetail.detail.user_count)) : "NR"} -
-
-
-
-
+ )} +
+
+

DETAILS

+ +
+
+
Address:
+
{locationDetail.detail.address} {locationDetail.detail.regency_name}
+
+ + +
+
Average Cost
+
Rp 25.000
+
+
+ +
+
+
Tags :
+
+ {locationDetail.tags.map((x, index) => ( +
+ Badge
+ ))} +
- {locationDetail.detail.user_count !== 0 && -
- Based on {locationDetail.detail.user_count} reviews -
- } -
-
-
-
-

DETAILS

- -
-
- address: {locationDetail.detail.address} {locationDetail.detail.regency_name} -
- - -
- average cost: IDR 25.0000 -
- -
- Tags: -
- {locationDetail.tags.map(x => ( -
- {x} -
- )) - } -
+ + ({ + 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 + })} + /> +
-
-
-
- {!user.username ? -
SIGN IN TO REVIEW
- : -
-
-
+
+
+
+ {!user.username ? +
SIGN IN TO REVIEW
+ : +
+
+ + -
+ -
+
{currentUserReview ? -
-

{currentUserReview.score}

-
-
+
+

{currentUserReview.score}

+
+
: @@ -351,24 +420,24 @@ function LocationDetail() { -
/ score
+
/ score
{pageState.is_score_rating_panic_msg && -
{pageState.is_score_rating_panic_msg}
+
{pageState.is_score_rating_panic_msg}
} } -
+
-
+
{currentUserReview ? }
- -
-
- Review Guidelines +
+
+ +

add image

+
+
+ + {pageState.on_submit_loading ? + + : + + + POST + + + }
- {pageState.on_submit_loading ? - - : - - - POST - - - }
} -
+
{locationDetail.critics_review.length > 0 ? <> -
-
Sort by:
- setPageState({ ...pageState, show_sort: !pageState.show_sort })}> -

{pageState.critic_filter_name}

- +
+
Sort by:
+
setPageState({ ...pageState, show_sort: !pageState.show_sort })}> +

{pageState.critic_filter_name}

+
- -
+
{locationDetail.critics_review.map(x => ( -
-
-
+
+
+
{x.score}
-
-
+
+
-
+ -
-
+ -
+
-
-
- - -
Video
+
+ - @@ -469,85 +543,85 @@ function LocationDetail() { : - No Critics review to display + No Critics review to display }
-
+
0 ? '#' : ''} /> {locationDetail.users_review.length > 0 ? <> {locationDetail.users_review.map(x => ( -
-
+
+
- -
-
{x.score}
-
-
+
+
{x.score}
+
+
-
+
-
-
- - -
Video
+
))} -
- + : <> - No users review to display + No users review to display }
-
+
CONTRUBITION anoeantoeh aoenthaoe aoenth aot
-
+
-
+
Added on: 28 May 1988
diff --git a/src/pages/LocationDetail/types.ts b/src/pages/LocationDetail/types.ts index b65e628..f3b4bf0 100755 --- a/src/pages/LocationDetail/types.ts +++ b/src/pages/LocationDetail/types.ts @@ -2,7 +2,7 @@ import { NullValueRes } from "../../types/common" import { SlideImage } from "yet-another-react-lightbox" export interface ILocationDetail { - id: Number, + id: number, name: String, address: String, regency_name: String, @@ -10,11 +10,11 @@ export interface ILocationDetail { region_name: String, google_maps_link: String, thumbnail: string | null, - submitted_by: Number, - critic_score: Number, - critic_count: Number, - user_score: Number, - user_count: Number + submitted_by: number, + critic_score: number, + critic_count: number, + user_score: number, + user_count: number } export function emptyLocationDetail(): ILocationDetail { @@ -64,14 +64,14 @@ export function EmptyLocationDetailResponse(): LocationDetailResponse { } export interface LocationImage extends SlideImage { - id: Number, + id: number, src: string, created_at: String, uploaded_by: String } export interface LocationResponse { - total_image: Number, + total_image: number, images: Array } @@ -83,13 +83,13 @@ export function emptyLocationResponse(): LocationResponse { } export type CurrentUserLocationReviews = { - id: Number, + id: number, comments: string, is_from_critic: boolean, is_hided: boolean, - location_id: Number, - score: Number, - submitted_by: Number, + location_id: number, + score: number, + submitted_by: number, created_at: NullValueRes<"Time", string>, updated_at: NullValueRes<"Time", string>, } \ No newline at end of file diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index bdeb53c..eba2f1a 100755 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -58,6 +58,11 @@ function Login() { }) 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) return; } @@ -96,6 +101,7 @@ function Login() { + {console.log(errorMsg)} {errorMsg.map(x => (

{x.msg}

))} diff --git a/src/services/auth.ts b/src/services/auth.ts index f36840b..7703340 100755 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,55 +1,76 @@ -import { AxiosError } from "axios"; -import { LOGIN_URI, SIGNUP_URI } from "../constants/api"; +import { useMutation, UseMutationOptions } from "@tanstack/react-query"; +import { LOGIN_URI, SIGNUP_URI, LOGOUT_URI } from "../constants/api"; import { client } from "./config"; import { IHttpResponse } from "../types/common"; -const initialState: IHttpResponse = { - data: null, - error: AxiosError -} - interface IAuthentication { - username: String - password: String + username: 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) => { + return useMutation({ + mutationFn: createAccount, + ...options + }) +} + +export const useLogin = (options?: UseMutationOptions) => { + return useMutation({ + mutationFn: login, + ...options + }) +} + +export const useLogout = (options?: UseMutationOptions) => { + return useMutation({ + mutationFn: logout, + ...options + }) +} + +// Legacy service functions for backward compatibility async function createAccountService({ username, password }: IAuthentication) { - const newState = { ...initialState }; try { - const response = await client({ method: 'POST', url: SIGNUP_URI, data: { username, password }, withCredentials: true }) - newState.data = response.data - newState.error = null - return newState + const data = await createAccount({ username, password }) + return { data, error: null } } catch (error) { - newState.error = error - return newState + return { data: null, error } } } async function loginService({ username, password }: IAuthentication) { - const newState = { ...initialState }; try { - const response = await client({ method: 'POST', url: LOGIN_URI, data: { username, password }, withCredentials: true }) - newState.data = response.data - newState.error = null - return newState + const data = await login({ username, password }) + return { data, error: null } } catch (error) { - newState.error = error - return newState + return { data: null, error } } - } async function logoutService() { - const newState = { ...initialState }; try { - const response = await client({ method: 'POST', url: LOGIN_URI}) - newState.data = response.data - newState.error = null - return newState + const data = await logout() + return { data, error: null } } catch (error) { - newState.error = error - return newState + return { data: null, error } } } diff --git a/src/services/config.ts b/src/services/config.ts index 62e757c..9fc9085 100755 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -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({ - method: props.method, - baseURL: `${BASE_URL}`, - url: props.url, - headers: props.headers, - data: props.data, - ...props -}) +interface FetchConfig extends RequestInit { + url: string; + data?: any; + withCredentials?: boolean; +} -// export const authClient = (props: AxiosRequestConfig) => axios({ -// method: props.method, -// baseURL: `${BASE_URL}`, -// url: props.url, -// headers: { -// 'Authorization': -// } -// }) \ No newline at end of file +export async function client(config: FetchConfig): Promise<{ data: T; status: number; request: { status: number } }> { + const { url, data, withCredentials, headers, ...rest } = config; + + const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`; + + 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; + 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 } + }; +} \ No newline at end of file diff --git a/src/services/images.ts b/src/services/images.ts index c441250..97fa7e0 100755 --- a/src/services/images.ts +++ b/src/services/images.ts @@ -1,34 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { GetRequestPagination } from "../types/common" import { GET_IMAGES_BY_LOCATION_URI } from "../constants/api" import { client } from "./config" -import statusCode from "./status-code" - -const initialState: any = { - data: null, - error: null -} interface getImagesReq extends GetRequestPagination { - location_id?: Number + location_id?: number } - -async function getImagesByLocationService({ page, page_size, location_id }: getImagesReq) { - const newState = { ...initialState } +// API Functions +const fetchImagesByLocation = async ({ page, page_size, location_id }: getImagesReq) => { 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, 'queryKey' | 'queryFn'>) => { + return useQuery({ + queryKey: ['images', 'location', params], + queryFn: () => fetchImagesByLocation(params), + enabled: !!params.location_id, + ...options + }) +} + +// Legacy service functions for backward compatibility +async function getImagesByLocationService({ page, page_size, location_id }: getImagesReq) { try { - const response = await client({ method: 'GET', url: url}) - switch (response.request.status) { - case statusCode.OK: - newState.data = response.data; - return newState - default: - newState.error = response.data; - return newState - } + const data = await fetchImagesByLocation({ page, page_size, location_id }) + return { data, error: null } } catch (error) { console.log(`GET IMAGE BY LOCATION SERVICE ERROR: ${error}`) + return { data: null, error } } } diff --git a/src/services/locations.ts b/src/services/locations.ts index 463ee67..b898996 100755 --- a/src/services/locations.ts +++ b/src/services/locations.ts @@ -1,21 +1,15 @@ +import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from "@tanstack/react-query"; import { GetRequestPagination, IHttpResponse } from "../types/common"; -import { - GET_LIST_LOCATIONS_URI, - GET_LIST_RECENT_LOCATIONS_RATING_URI, - GET_LIST_TOP_LOCATIONS, - GET_LOCATION_TAGS_URI, +import { + GET_LIST_LOCATIONS_URI, + GET_LIST_RECENT_LOCATIONS_RATING_URI, + GET_LIST_TOP_LOCATIONS, + GET_LOCATION_TAGS_URI, GET_LOCATION_URI, GET_SEARCH_LOCATIONS_URI, POST_CREATE_LOCATION } from "../constants/api"; import { client } from "./config"; -import statusCode from "./status-code"; -import { AxiosError } from "axios"; - -const initialState: any = { - data: null, - error: null -} interface GetListLocationsArg extends GetRequestPagination { order_by?: number, @@ -27,131 +21,181 @@ interface GetSearchLocations extends GetRequestPagination { filter?: string } -async function getListLocationsService({ page, page_size }: GetListLocationsArg) { - const newState = { ...initialState }; +// API Functions +const fetchListLocations = async ({ page, page_size }: GetListLocationsArg) => { 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, 'queryKey' | 'queryFn'>) => { + return useQuery({ + queryKey: ['locations', params], + queryFn: () => fetchListLocations(params), + ...options + }) +} + +export const useRecentLocationsRatings = (page_size: number, page: number, options?: Omit, 'queryKey' | 'queryFn'>) => { + return useQuery({ + queryKey: ['locations', 'recent-ratings', page_size, page], + queryFn: () => fetchRecentLocationsRatings(page_size, page), + ...options + }) +} + +export const useTopLocations = (params: GetListLocationsArg, options?: Omit, 'queryKey' | 'queryFn'>) => { + return useQuery({ + queryKey: ['locations', 'top', params], + queryFn: () => fetchTopLocations(params), + ...options + }) +} + +export const useLocation = (id: number, options?: Omit, 'queryKey' | 'queryFn'>) => { + return useQuery({ + queryKey: ['location', id], + queryFn: () => fetchLocation(id), + enabled: !!id, + ...options + }) +} + +export const useLocationTags = (id: number, options?: Omit, 'queryKey' | 'queryFn'>) => { + return useQuery({ + queryKey: ['location', 'tags', id], + queryFn: () => fetchLocationTags(id), + enabled: !!id, + ...options + }) +} + +export const useSearchLocations = (params: GetSearchLocations, options?: Omit, 'queryKey' | 'queryFn'>) => { + return useQuery({ + queryKey: ['locations', 'search', params], + queryFn: () => searchLocations(params), + enabled: !!params.name, + ...options + }) +} + +export const useCreateLocation = (options?: UseMutationOptions) => { + return useMutation({ + mutationFn: createLocation, + ...options + }) +} + +// Legacy service functions for backward compatibility +async function getListLocationsService({ page, page_size }: GetListLocationsArg) { try { - const response = await client({ method: 'GET', url: url }) - switch (response.request.status) { - case statusCode.OK: - newState.data = response.data; - return newState; - default: - newState.error = response.data; - return newState - } + const data = await fetchListLocations({ page, page_size }) + return { data, error: null } } catch (error) { - console.log(error) + return { data: null, error } } } 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 { - const response = await client({ method: 'GET', url: url }) - switch (response.request.status) { - case statusCode.OK: - newState.data = response.data; - return newState; - default: - newState.error = response.data; - return newState - } + const data = await fetchRecentLocationsRatings(page_size, page) + return { data, error: null } } catch (error) { - console.log(error) + return { data: null, error } } } -async function getListTopLocationsService({ page, page_size, order_by, region_type }: GetListLocationsArg) { - const newState = { ...initialState }; - const url = `${GET_LIST_TOP_LOCATIONS}?page=${page}&page_size=${page_size}&order_by=${order_by}®ion_type=${region_type}` +async function getListTopLocationsService(params: GetListLocationsArg) { try { - const response = await client({ method: 'GET', url: url }) - switch (response.request.status) { - case statusCode.OK: - newState.data = response.data; - return newState; - default: - newState.error = response.data; - return newState - } + const data = await fetchTopLocations(params) + return { data, error: null } } catch (error) { - console.log(error) + return { data: null, error } } } -async function getLocationService(id: Number) { - const newState = { ...initialState }; - const url = `${GET_LOCATION_URI}/${id}` +async function getLocationService(id: number) { try { - const response = await client({ method: 'GET', url: url }) - switch (response.request.status) { - case statusCode.OK: - newState.data = response.data; - return newState; - default: - newState.error = response.data; - return newState; - } + const data = await fetchLocation(id) + return { data, error: null } } catch (error) { - throw(error) + throw error } } -async function getLocationTagsService(id: Number) { - const newState = { ...initialState }; - const url = `${GET_LOCATION_TAGS_URI}/${id}` +async function getLocationTagsService(id: number) { try { - const response = await client({ method: 'GET', url: url }) - switch (response.request.status) { - case statusCode.OK: - newState.data = response.data; - return newState; - default: - newState.error = response.data; - return newState; - } + const data = await fetchLocationTags(id) + return { data, error: null } } catch (error) { - console.log(error) + return { data: null, error } } } async function createLocationService(data: FormData): Promise { - const newState: IHttpResponse = { data: null, error: null}; - try { - const response = await client({ method: 'POST', url: POST_CREATE_LOCATION, data: data, withCredentials: true}) - newState.data = response.data; - newState.status = response.status - return newState; - } catch (error) { - let err = error as AxiosError; - newState.error = err; - newState.status = err.status; - return newState; + try { + const responseData = await createLocation(data) + return { data: responseData, error: null } + } catch (error: any) { + return { + data: null, + error, + status: error.status + } } } async function getSearchLocationService(arg: GetSearchLocations): Promise { - const newState: IHttpResponse = { data: null, error: null}; - try { - 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}` - }) - - 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; + const data = await searchLocations(arg) + return { data, error: null } + } catch(error: any) { + return { + data: null, + error, + status: error.status + } } } diff --git a/src/services/news.ts b/src/services/news.ts index 0f0b6e2..ea3d857 100755 --- a/src/services/news.ts +++ b/src/services/news.ts @@ -1,4 +1,4 @@ -import { AxiosError } from "axios"; +import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from "@tanstack/react-query"; import { GetRequestPagination, IHttpResponse } from "..//types/common"; import { client } from "./config"; 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 } -async function getNewsServices({ page, page_size, is_with_approval}: GetNewsSevice): Promise { - 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 { title: string, url: string, @@ -29,18 +14,52 @@ interface PostNewsServiceBody { submitted_by: number } -async function postNewsService(req: PostNewsServiceBody): Promise { - const newState: IHttpResponse = { data: null, error: null} +// API Functions +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, 'queryKey' | 'queryFn'>) => { + return useQuery({ + queryKey: ['news', params], + queryFn: () => fetchNews(params), + ...options + }) +} + +export const usePostNews = (options?: UseMutationOptions) => { + return useMutation({ + mutationFn: postNews, + ...options + }) +} + +// Legacy service functions for backward compatibility +async function getNewsServices({ page, page_size, is_with_approval}: GetNewsSevice): Promise { try { - const response = await client({ method: 'POST', url: POST_NEWS_EVENTS_URI, data: req, withCredentials: true}) - newState.data = response.data - newState.status = response.status - return newState - } catch (error) { - let err = error as AxiosError; - newState.error = err; - newState.status = err.status - throw(newState) + const data = await fetchNews({ page, page_size, is_with_approval }) + return { data, error: null } + } catch (error: any) { + throw { data: null, error, status: error.status } + } +} + +async function postNewsService(req: PostNewsServiceBody): Promise { + try { + const data = await postNews(req) + return { data, error: null } + } catch (error: any) { + throw { data: null, error, status: error.status } } } diff --git a/src/services/regions.ts b/src/services/regions.ts index 394bdb6..331b21c 100755 --- a/src/services/regions.ts +++ b/src/services/regions.ts @@ -1,41 +1,74 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { client } from "./config"; import { GET_PROVINCES, GET_REGENCIES, GET_REGIONS } from "../constants/api"; 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, 'queryKey' | 'queryFn'>) => { + return useQuery({ + queryKey: ['regions'], + queryFn: fetchRegions, + ...options + }) +} + +export const useProvinces = (options?: Omit, 'queryKey' | 'queryFn'>) => { + return useQuery({ + queryKey: ['provinces'], + queryFn: fetchProvinces, + ...options + }) +} + +export const useRegencies = (options?: Omit, 'queryKey' | 'queryFn'>) => { + return useQuery({ + queryKey: ['regencies'], + queryFn: fetchRegencies, + ...options + }) +} + +// Legacy service functions for backward compatibility async function getRegionsService(): Promise { - const newState: IHttpResponse = {data: null, error: null} try { - const response = await client({ method: 'GET', url: GET_REGIONS}) - newState.data = response.data; - return newState + const data = await fetchRegions() + return { data, error: null } } catch(err) { - newState.error = err - throw (newState) + throw { data: null, error: err } } } async function getProvincesService(): Promise { - const newState: IHttpResponse = { data: null, error: null} try { - const response = await client({ method: 'GET', url: GET_PROVINCES}) - newState.data = response.data; - return newState + const data = await fetchProvinces() + return { data, error: null } } catch(err) { - newState.error = err - throw (newState) + throw { data: null, error: err } } } async function getRegenciesService(): Promise { - const newState: IHttpResponse = { data: null, error: null}; try { - const response = await client({ method: 'GET', url: GET_REGENCIES}) - newState.data = response.data; - newState.status = response.status - return newState + const response = await client({ method: 'GET', url: GET_REGENCIES }) + return { data: response.data, error: null, status: response.status } } catch(err) { - newState.error = err - throw (newState) + throw { data: null, error: err } } } diff --git a/src/services/review.ts b/src/services/review.ts index f4747dd..62a83c4 100755 --- a/src/services/review.ts +++ b/src/services/review.ts @@ -1,14 +1,8 @@ -import { AxiosError } from "axios" +import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from "@tanstack/react-query"; import { client } from "./config"; import { GET_CURRENT_USER_REVIEW_LOCATION_URI, POST_REVIEW_LOCATION_URI } from "../constants/api"; import { IHttpResponse } from "src/types/common"; -const initialState: IHttpResponse = { - data: null, - error: AxiosError, - status: 0, -} - interface postReviewLocationReq { submitted_by: number, comments: string, @@ -18,31 +12,54 @@ interface postReviewLocationReq { 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) => { + return useMutation({ + mutationFn: postReview, + ...options + }) +} + +export const useCurrentUserLocationReview = (location_id: number, options?: Omit, '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) { - const newState = { ...initialState }; try { - const response = await client({ method: 'POST', url: POST_REVIEW_LOCATION_URI, data: req, withCredentials: true}) - newState.data = response.data - newState.error = null - return newState + const data = await postReview(req) + return { data, error: null } } catch (error) { - newState.error = error - throw(error) + throw error } } async function getCurrentUserLocationReviewService(location_id: number): Promise { - const newState = { ...initialState }; try { - const response = await client({ method: 'GET', url: `${GET_CURRENT_USER_REVIEW_LOCATION_URI}/${location_id}`, withCredentials: true}) - newState.data = response.data - newState.error = null - return newState - } catch (err) { - let error = err as AxiosError; - newState.error = error - newState.status = error.response?.status; - throw(newState) + const data = await fetchCurrentUserLocationReview(location_id) + return { data, error: null } + } catch (error: any) { + throw { data: null, error, status: error.status } } } diff --git a/src/services/users.ts b/src/services/users.ts index 5f75ee3..135dac5 100755 --- a/src/services/users.ts +++ b/src/services/users.ts @@ -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 { IHttpResponse } from "../types/common"; import { client } from "./config"; 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, 'queryKey' | 'queryFn'>) => { + return useQuery({ + queryKey: ['user', 'stats'], + queryFn: fetchUserStats, + ...options + }) +} + +export const usePatchUserAvatar = (options?: UseMutationOptions) => { + return useMutation({ + mutationFn: patchUserAvatar, + ...options + }) +} + +export const usePatchUserInfo = (options?: UseMutationOptions) => { + return useMutation({ + mutationFn: patchUserInfo, + ...options + }) +} + +export const useDeleteUserAvatar = (options?: UseMutationOptions) => { + return useMutation({ + mutationFn: deleteUserAvatar, + ...options + }) +} + +// Legacy service functions for backward compatibility async function getUserStatsService(): Promise { - const newState: IHttpResponse = { data: null, error: null }; try { - const res = await client({ method: 'GET', url: GET_CURRENT_USER_STATS, withCredentials: true}) - newState.data = res.data - newState.status = res.status - return newState - } catch(error) { - let err = error as AxiosError - newState.error = err - newState.status = err.status - throw(newState) + const data = await fetchUserStats() + return { data, error: null } + } catch(error: any) { + throw { data: null, error, status: error.status } } } async function patchUserAvatarService(form: FormData): Promise { - const newState: IHttpResponse = { data: null, error: null}; try { - const res = await client({ method: "PATCH", url: PATCH_USER_AVATAR, data: form, withCredentials: true}) - newState.data = res.data; - newState.status = res.status; - return newState; - } catch(error) { - let err = error as AxiosError; - newState.error = err - newState.status = err.status - throw(newState); + const data = await patchUserAvatar(form) + return { data, error: null } + } catch(error: any) { + throw { data: null, error, status: error.status } } } async function patchUserInfoService(data: UserInfo): Promise { - const newState: IHttpResponse = { data: null, error: null}; try { - const res = await client({ method: 'PATCH', url: PATCH_USER_INFO, data: data, withCredentials: true}) - newState.data = res.data; - newState.status = res.status; - return newState; - } catch(error) { - let err = error as AxiosError; - newState.error = err; - newState.status = err.status; - throw(newState); + const responseData = await patchUserInfo(data) + return { data: responseData, error: null } + } catch(error: any) { + throw { data: null, error, status: error.status } } } async function deleteUserAvatarService(): Promise { - const newState: IHttpResponse = { data: null, error: null}; try { - const res = await client({ method: 'DELETE', url: DELETE_USER_AVATAR, withCredentials: true}) - newState.data = res.data; - newState.status = res.status - return newState - } catch (error) { - let err = error as AxiosError; - newState.error = err; - newState.status = err.status; - throw(newState); + const data = await deleteUserAvatar() + return { data, error: null } + } catch (error: any) { + throw { data: null, error, status: error.status } } } diff --git a/src/utils/common.ts b/src/utils/common.ts index d7553b8..954df98 100755 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,4 +1,6 @@ import { AxiosError } from "axios"; +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" export function handleAxiosError(error: AxiosError) { return error.response?.data @@ -11,4 +13,8 @@ export function enumKeys(obj: O): export function isUrl(val: string): boolean { var urlPattern = /^https:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/; return urlPattern.test(val); -} \ No newline at end of file +} + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/tests/example.spec.ts b/tests/example.spec.ts new file mode 100644 index 0000000..54a906a --- /dev/null +++ b/tests/example.spec.ts @@ -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(); +});