Compare commits

..

34 Commits

Author SHA1 Message Date
946bed296a add news evenst page 2023-10-11 16:31:25 +07:00
1c65b5b237 handling is string an url 2023-10-11 16:31:15 +07:00
8eee747a3b remove unused 2023-10-11 16:30:31 +07:00
7a137b3507 add text area styles 2023-10-11 16:30:09 +07:00
ac57a14b88 new color 2023-10-11 16:29:49 +07:00
ca6a77e825 add textarea autosize 2023-10-11 16:29:41 +07:00
afaa18f75e update readme 2023-10-11 16:28:35 +07:00
d5e79272fb fix fontsize for mobile 2023-10-09 20:13:28 +07:00
905ee3669c add user settings, add protected route 2023-10-09 16:24:45 +07:00
68cc693e3f remove unused var 2023-10-09 16:24:02 +07:00
781e9cdb79 remove consoles 2023-10-09 16:23:42 +07:00
25b9877f3a add separator, buttons 2023-10-09 16:23:08 +07:00
41cb93d39f add gray color 2023-10-09 16:22:35 +07:00
79dc198df9 add user profile stats 2023-10-04 19:55:03 +07:00
6a857254c2 fix import 2023-10-04 19:53:58 +07:00
e4e6f5fa86 edit header 2023-10-04 19:53:08 +07:00
76f28efb07 add new routes in header 2023-10-04 19:52:41 +07:00
40b8b5f112 add create new locations and get indonesia regions 2023-10-03 14:53:42 +07:00
0b0c2c0db6 fix and refactor types 2023-10-03 14:53:20 +07:00
408ff41b7d fix home location info state 2023-10-03 14:52:42 +07:00
f1b508685e add protected routes component 2023-10-03 14:52:25 +07:00
4bc60a6dd3 fix and refactor types 2023-10-03 14:52:06 +07:00
cdb3fa2955 fix login styling 2023-10-03 14:39:54 +07:00
1fe205165d add Location info domain 2023-10-03 14:39:08 +07:00
13391b18eb add dropdown inputs 2023-10-03 14:38:49 +07:00
9fb697f4ef add new domains 2023-10-03 14:38:36 +07:00
f4f5799590 add submissions and user menu z index 2023-10-03 14:38:14 +07:00
c9d6118f0c add protected routes for submissions 2023-10-03 14:37:43 +07:00
edc856bb81 update readme 2023-10-03 14:36:58 +07:00
72a96f72f5 update title 2023-10-03 14:36:40 +07:00
9c4fc6ddcd update page after submit review 2023-09-28 10:38:08 +07:00
788610292e handling erorr response and empty reviews 2023-09-28 10:29:31 +07:00
96185ab95a add current user location review 2023-09-27 22:46:14 +07:00
3980f11f18 fix auth error 2023-09-27 22:34:32 +07:00
67 changed files with 2414 additions and 235 deletions

View File

@ -1 +1,7 @@
yik hiling gis bir gik cipik mikirin kihidipin ying simintiri ini
yik hiling gis bir gik cipik mikirin kihidipin ying simintiri ini
## RESOURCES
- https://react-select.com/home (MAYBE USE THIS ONE OVER MY OWN DOGSHIT SELECT)
- https://github.com/Upstatement/react-router-guards

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Preact + TS</title>
<title>Hilingin</title>
</head>
<body>
<div id="app"></div>

View File

@ -21,6 +21,7 @@
"react": "^18.2.0",
"react-redux": "^8.1.2",
"react-router-dom": "^6.16.0",
"react-textarea-autosize": "^8.5.3",
"redux-persist": "^6.0.0",
"redux-thunk": "^2.4.2",
"yet-another-react-lightbox": "^3.12.2"

View File

@ -10,6 +10,17 @@
border-color: #38444d;
}
.text-area {
border: none;
overflow: auto;
outline: none;
box-shadow: none;
background-color: #40444b;
width: 100%;
min-height: 100px;
overflow-y: hidden;
}
.content {
max-width: 1440px;
margin: 0 auto;

View File

@ -2,24 +2,61 @@ import { Route, Routes } from 'react-router-dom'
import { BrowserRouter as Router } from 'react-router-dom'
import './app.css'
import { DefaultLayout } from './layouts'
import routes from './routes'
import "yet-another-react-lightbox/styles.css";
import { Login, NotFound } from './pages'
import { Login, NotFound, Submissions } from './pages'
import { Provider } from 'react-redux'
import { persistore, store } from './store/config'
import { PersistGate } from 'redux-persist/integration/react'
import { AdminProtectedRoute, UserProtectedRoute } from './routes/ProtectedRoute'
import { getRoutes } from './routes';
export function App() {
const { routes } = getRoutes();
return (
<>
<Provider store={store}>
<PersistGate persistor={persistore}>
<Router>
<Routes>
<Route path='/login' element={<Login />} />
<Route element={<DefaultLayout />}>
{routes.map(({ path, name, element }) => (
<Provider store={store}>
<PersistGate persistor={persistore}>
<Router>
<Routes>
<Route path='/login' element={<Login />} />
<Route element={<DefaultLayout />}>
{routes.map(({ path, name, element, protectedRoute }) => {
let Element = element as any
if (protectedRoute === "user") {
return (
<>
<Route
path={path}
id={name}
element={
<UserProtectedRoute>
<Element />
</UserProtectedRoute>
}
/>
</>
)
}
if (protectedRoute === "admin") {
return (
<>
<>
<Route
path={path}
id={name}
element={
<AdminProtectedRoute>
<Element />
</AdminProtectedRoute>
}
/>
</>
</>
)
}
return (
<>
<Route
path={path}
@ -27,13 +64,14 @@ export function App() {
element={element}
/>
</>
))}
</Route>
)
})}
<Route path="*" element={<NotFound />} />
</Routes>
</Router>
</PersistGate>
</Provider>
</Route>
</Routes>
</Router>
</PersistGate>
</Provider>
</>
)
}

View File

@ -0,0 +1,37 @@
import { TargetedEvent, CSSProperties } from "preact/compat";
import './style.css';
import { SpinnerLoading } from "../..";
interface DefaultButtonProps {
label: string
containerClassName?: string,
containerStyle?: CSSProperties,
className?: string,
style?: CSSProperties
onClick?: (event: TargetedEvent) => any,
href?: string,
isLoading?: boolean
}
const DefaultButton = (props: DefaultButtonProps) => (
<div
className={props.containerClassName}
style={props.containerStyle}
>
<a
href={props.href}
onClick={props.onClick}
className={`bg-gray default-button ${props.className ? props.className : ''}`}
style={{ paddingTop: 6, paddingBottom: 6, letterSpacing: .9, borderRadius: 7, ...props.style }}
>
{ props.isLoading ?
<SpinnerLoading style={{ verticalAlign: 'middle'}}/>
:
props.label
}
</a>
</div>
)
export default DefaultButton;

View File

@ -0,0 +1,4 @@
div a.default-button:hover {
color: white;
background-color: #575757;
}

View File

@ -0,0 +1,36 @@
import { CSSProperties, TargetedEvent } from "preact/compat";
import './style.css';
import { SpinnerLoading } from "../..";
interface DefaultButtonProps {
label: string
containerClassName?: string,
containerStyle?: CSSProperties,
className?: string,
style?: CSSProperties,
isLoading?: boolean,
onClick?: (event: TargetedEvent) => any,
href?: string,
}
const WarningButton = (props: DefaultButtonProps) => (
<div
className={props.containerClassName}
style={props.containerStyle}
>
<a
href={props.href}
onClick={props.onClick}
className={`bg-error warning-button ${props.className ? props.className : ''}`}
style={{ paddingTop: 6, paddingBottom: 6, letterSpacing: .9, borderRadius: 7, ...props.style }}
>
{props.isLoading ?
<SpinnerLoading style={{ verticalAlign: 'middle'}}/>
:
props.label
}
</a>
</div>
)
export default WarningButton;

View File

@ -0,0 +1,4 @@
div a.warning-button:hover{
color: white;
background-color: #af3030;
}

View File

@ -0,0 +1,128 @@
import { ChangeEvent, useEffect, useRef, useState } from "react";
import "./style.css";
const Icon = () => {
return (
<svg height="20" width="20" viewBox="0 0 20 20" fill={"white"}>
<path d="M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z"></path>
</svg>
);
};
interface dropdownProps {
placeholder: string,
options: Array<any>,
labelPropsName: string,
isSearchable: boolean,
onChange: Function
}
const DropdownInput = ({
placeholder,
options,
isSearchable,
labelPropsName,
onChange
}: dropdownProps) => {
const [showMenu, setShowMenu] = useState<boolean>(false);
const [selectedValue, setSelectedValue] = useState<any>();
const [searchValue, setSearchValue] = useState("");
const searchRef = useRef<any>();
const inputRef = useRef<any>();
useEffect(() => {
setSearchValue("");
if (showMenu && searchRef.current) {
searchRef.current.focus();
}
}, [showMenu]);
useEffect(() => {
const handler = (e: any) => {
if (inputRef.current && !inputRef.current.contains(e.target)) {
setShowMenu(false);
}
};
window.addEventListener("click", handler);
return () => {
window.removeEventListener("click", handler);
};
});
const handleInputClick = () => {
setShowMenu(!showMenu);
};
const getDisplay = () => {
if (!selectedValue) {
return placeholder;
}
return selectedValue[labelPropsName]
};
const onItemClick = (option: any) => {
setSelectedValue(option)
onChange(option);
};
const isSelected = (option: any) => {
if (!selectedValue) {
return false;
}
return selectedValue[labelPropsName] === option[labelPropsName];
};
const onSearch = (e: ChangeEvent) => {
const event = e.target as HTMLInputElement
setSearchValue(event.value);
};
const getIDropdownInputPropss = () => {
if (!searchValue) {
return options;
}
return options.filter(
(option: any) =>
option[labelPropsName].toLowerCase().indexOf(searchValue.toLowerCase()) >= 0
);
};
return (
<div className="dropdown-container">
<div ref={inputRef} onClick={handleInputClick} className="dropdown-input">
<div className="dropdown-selected-value text-sm">{getDisplay()}</div>
<div className="dropdown-tools">
<div className="dropdown-tool">
<Icon />
</div>
</div>
</div>
{showMenu && (
<div className="dropdown-item-menu">
{isSearchable && (
<div className="search-box bg-secondary">
<input className={'bg-primary text-sm'} onChange={onSearch} value={searchValue} ref={searchRef} />
</div>
)}
<div class={'dropdown-item-container'}>
{getIDropdownInputPropss().map((option: any) => (
<div
onClick={() => onItemClick(option)}
key={option[labelPropsName]}
className={`dropdown-item text-sm ${isSelected(option) && "selected"}`}
>
{option[labelPropsName]}
</div>
))}
</div>
</div>
)}
</div>
);
};
export default DropdownInput;

View File

@ -0,0 +1,104 @@
.dropdown-container {
text-align: left;
border: 1px solid #474747;
position: relative;
border-radius: 5px;
min-width: 325px;
max-width: 450px;
width: 100%;
}
.dropdown-input {
padding: 5px;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
}
.dropdown-input-menu {
display: block;
position: absolute;
transform: translateY(4px);
width: 100%;
border: 1px solid #ccc;
border-radius: 5px;
overflow: auto;
max-height: 150px;
background-color: #fff;
}
.dropdown-item-container {
max-height: 200px;
overflow-y: scroll;
}
/* width */
.dropdown-item-container::-webkit-scrollbar {
width: 8px;
}
/* Track */
.dropdown-item-container::-webkit-scrollbar-track {
background: #212121;
}
/* Handle */
.dropdown-item-container::-webkit-scrollbar-thumb {
background: #888;
}
/* Handle on hover */
.dropdown-item-container::-webkit-scrollbar-thumb:hover {
background: #555;
}
.dropdown-item {
padding: 5px 10px;
cursor: pointer;
}
.dropdown-item:hover {
background-color: #9fc3f870;
}
.dropdown-item.selected {
background-color: #a8adb3;
color: #fff;
}
.dropdown-selected-value {
padding: 5px 10px;
}
.dropdown-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.dropdown-tag-item {
background-color: #ddd;
padding: 2px 4px;
border-radius: 2px;
display: flex;
align-items: center;
}
.dropdown-tag-close {
display: flex;
align-items: center;
}
.search-box {
padding: 5px;
background-color: #eee;
}
.search-box input {
width: 100%;
box-sizing: border-box;
padding: 5px 10px;
border-radius: 5px;
}

View File

@ -1,4 +1,4 @@
import React, { TargetedEvent } from "preact/compat";
import React from "preact/compat";
import { useState } from "preact/hooks";
import { useSelector, useDispatch } from "react-redux";
import { UserRootState } from "../../store/type";
@ -22,7 +22,7 @@ function Header() {
setSearchVal(val.value)
}
const handleLogout = async (): Promise<void> => {
const handleLogout = async (): Promise<void> => {
try {
await logoutService()
dispatch(logout())
@ -60,18 +60,19 @@ function Header() {
src={'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'}
/>
</a>
{user.username &&
<div className={'profile-dropdown-img bg-secondary text-left'} style={pageState.profileMenu ? { display: 'block'} : { display: 'none'}}>
<a href={'#'}><div className={'p-2'}>Profile</div></a>
<a href={'#'}><div className={'p-2'}>Feed</div></a>
<a href={'#'}><div className={'p-2'}>Add location</div></a>
<a href={'#'} onClick={handleLogout}><div className={'p-2'}>Logout</div></a>
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
</div>
{user.username &&
<div className={'profile-dropdown-img bg-secondary text-left'} style={pageState.profileMenu ? { display: 'block' } : { display: 'none' }}>
<a href={'/user/profile'}><div className={'p-2'}>Profile</div></a>
<a href={'#'}><div className={'p-2'}>Feed</div></a>
<a href={'/add-location'}><div className={'p-2'}>Add location</div></a>
<a href={'/add-location'}><div className={'p-2'}>Settings</div></a>
<a href={'#'} onClick={handleLogout}><div className={'p-2'}>Logout</div></a>
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
</div>
}
</div>
<form onSubmit={onSearchSubmit} className={`search-input ${dropdown ? "search-input-dropdown" : ""}`}>
<form onSubmit={onSearchSubmit} className={`header-search-input ${dropdown ? "search-input-dropdown" : ""}`}>
<label>
<input
type="text"
@ -98,10 +99,14 @@ function Header() {
<div className={'profile-container'}>
<a href={user.username ? '#' : '/login'} onClick={() => user.username ? setPageState({ ...pageState, profileMenu: !pageState.profileMenu }) : ''} className={`navLink ${!dropdown ? "navLink-disabled" : ""}`}>{user.username ? user.username : 'Sign in'}</a>
{user && screen.width > 600 &&
<div className={'profile-dropdown bg-secondary ml-6'} style={pageState.profileMenu ? { display: 'block' } : {display: 'none'}}>
<a href={'#'}><div className={'p-1'}>Profile</div></a>
<div className={'profile-dropdown bg-secondary ml-6'} style={pageState.profileMenu ? { display: 'block' } : { display: 'none' }}>
<a href={'/add-location'}><div className={'p-1'}>Add location</div></a>
<a href={'/user/profile'}><div className={'p-1'}>Profile</div></a>
<a href={'#'}><div className={'p-1'}>Feed</div></a>
<a href={'#'}><div className={'p-1'}>Add location</div></a>
<a href={'/user/settings'}><div className={'p-1'}>Settings</div></a>
{user.is_admin &&
<a href={'/submissions'} ><div className={'p-1'}>Submissions</div></a>
}
<a href={'#'} onClick={handleLogout}><div className={'p-1'}>Logout</div></a>
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}
{/* <div className={'p-2'}><a href={'#'}>Halo</a></div> */}

View File

@ -3,11 +3,11 @@
}
label {
.header-search-input label {
position: relative;
}
label:before {
.header-search-input label:before {
content: "";
position: absolute;
left: 10px;
@ -41,6 +41,7 @@ label:before {
padding: 5px;
width: 135px;
font-size: 13px;
z-index: 9999;
}
.profile-dropdown a div:hover {
@ -76,7 +77,7 @@ label:before {
text-align: 'center'; */
}
.search-input {
.header-search-input {
margin-left: auto;
}
@ -124,7 +125,7 @@ label:before {
display: none;
}
form.search-input {
form.header-search-input {
display: none;
margin-left: 0;
}

View File

@ -1,6 +1,13 @@
import { CSSProperties } from 'preact/compat'
import './style.css'
export default function SpinnerLoading() {
interface SpinnerLoadingProps {
style?: CSSProperties
className?: string
}
export default function SpinnerLoading(props: SpinnerLoadingProps) {
return (
<div className={'spinner'} style={{ display: 'inline-block'}}></div>
<div className={`spinner ${props.className ? props.className : ''}`} style={{ display: 'inline-block', ...props.style}}></div>
)
}

View File

@ -0,0 +1,37 @@
import { useRef, useEffect } from "preact/compat";
interface RefOutsideClickInterface {
onOutsideClick: Function,
children: any
}
/**
* https://stackoverflow.com/questions/32553158/detect-click-outside-react-component
*/
function useRefOutsideClick(ref: any, onOutsideClick: Function) {
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target)) {
onOutsideClick()
}
}
// Bind the event listener
document.addEventListener("mousedown", handleClickOutside);
return () => {
// Unbind the event listener on clean up
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref]);
}
/**
* Component that alerts if you click outside of it
*/
function RefOutsideClick(props: RefOutsideClickInterface) {
const wrapperRef = useRef(null);
useRefOutsideClick(wrapperRef, props.onOutsideClick);
return <div ref={wrapperRef}>{props.children}</div>;
}
export default RefOutsideClick;

View File

@ -0,0 +1,27 @@
import { CSSProperties, TargetedEvent } from 'preact/compat';
import './style.css';
type NavigationSeparatorProps = {
pageNames: Array<string>,
onClick: (e: TargetedEvent, name: string) => void,
titleStyles?: any,
containerStyle?: CSSProperties
selectedValue?: string | number
}
function NavigationSeparator(props: NavigationSeparatorProps) {
return (
<div class={"divider mb-2 navigation-separator-container"} style={props.containerStyle}>
{props.pageNames.map(x => (
<a
className={`text-xs font-bold uppercase mr-5 ${props.selectedValue != x.toLowerCase() ? 'text-gray' : 'pointer-events-none'}`}
style={{...props.titleStyles, letterSpacing: .9}}
onClick={(e) => props.onClick(e, x)}>
{x}
</a>
))}
</div>
)
}
export default NavigationSeparator;

View File

@ -0,0 +1,3 @@
.navigation-separator-text:hover {
color: white
}

View File

@ -0,0 +1,17 @@
type SeparatorProps = {
pageName: string,
titleStyles?: any,
titleClasses?: string
}
function TitleSeparator(props: SeparatorProps) {
return (
<div class={"flex flex-row justify-between divider mb-2"}>
<h2 className={`text-sm font-bold uppercase ${props.titleClasses}`} style={props.titleStyles}>
{props.pageName}
</h2>
</div>
)
}
export default TitleSeparator;

View File

@ -4,12 +4,13 @@ type SeparatorProps = {
pageName: String,
pageLink: string,
secondLink?: string,
titleStyles?: any
}
function SeparatorWithAnchor(props: SeparatorProps) {
return (
<div class={"flex flex-row justify-between divider mb-2"}>
<h1 className="text-sm font-bold" style={{ textTransform: 'uppercase' }}>
<h1 className="text-sm font-bold uppercase" style={props.titleStyles}>
<a href={props.pageLink}>{props.pageName}</a>
</h1>
{props.secondLink &&

View File

@ -1,17 +1,34 @@
import Header from "./Header";
import RefOutsideClick from "./RefOutsideClick";
import WarningButton from "./Button/WarningButton";
import DefaultButton from "./Button/DefaultButton";
import SeparatorWithAnchor from "./Separator/WithAnchor";
import DefaultSeparator from "./Separator/Default";
import TitleSeparator from "./Separator/TitleSeparator";
import NavigationSeparator from "./Separator/NavigationSeparator";
import Footer from './Footer/';
import CustomInterweave from "./CustomInterweave";
import DropdownInput from "./DropdownInput";
import SpinnerLoading from "./Loading/Spinner";
export {
Header,
WarningButton,
DefaultButton,
RefOutsideClick,
SeparatorWithAnchor,
DefaultSeparator,
TitleSeparator,
NavigationSeparator,
Footer,
CustomInterweave,
DropdownInput,
SpinnerLoading,
}

View File

@ -1,29 +1,54 @@
const BASE_URL = "http://localhost:8888"
const SIGNUP_URI = `${BASE_URL}/user/signup`
const LOGIN_URI = `${BASE_URL}/user/login`
const LOGOUT_URI = `${BASE_URL}/user/logout`
const SIGNUP_URI = `${BASE_URL}/user/signup`
const LOGIN_URI = `${BASE_URL}/user/login`
const LOGOUT_URI = `${BASE_URL}/user/logout`
const GET_LIST_LOCATIONS_URI = `${BASE_URL}/locations`;
const GET_LIST_TOP_LOCATIONS = `${BASE_URL}/locations/top-ratings`
const GET_LIST_RECENT_LOCATIONS_RATING_URI = `${BASE_URL}/locations/recent`
const GET_LOCATION_URI = `${BASE_URL}/location`;
const GET_LOCATION_TAGS_URI = `${BASE_URL}/location/tags`
const GET_REGIONS = `${BASE_URL}/regions`;
const GET_REGENCIES = `${BASE_URL}/region/regencies`;
const GET_PROVINCES = `${BASE_URL}/region/provinces`;
const GET_IMAGES_BY_LOCATION_URI = `${BASE_URL}/images/location`
const GET_CURRENT_USER_STATS = `${BASE_URL}/user/profile`;
const PATCH_USER_AVATAR = `${BASE_URL}/user/avatar`;
const PATCH_USER_INFO = `${BASE_URL}/user`;
const DELETE_USER_AVATAR = `${BASE_URL}/user/avatar`
const POST_REVIEW_LOCATION_URI = `${BASE_URL}/review/location`
const GET_NEWS_EVENTS_URI = `${BASE_URL}/news-events`;
const POST_NEWS_EVENTS_URI = GET_NEWS_EVENTS_URI
const GET_LIST_LOCATIONS_URI = `${BASE_URL}/locations`;
const GET_LIST_TOP_LOCATIONS = `${BASE_URL}/locations/top-ratings`
const GET_LIST_RECENT_LOCATIONS_RATING_URI = `${BASE_URL}/locations/recent`
const GET_LOCATION_URI = `${BASE_URL}/location`;
const GET_LOCATION_TAGS_URI = `${BASE_URL}/location/tags`
const POST_CREATE_LOCATION = GET_LIST_LOCATIONS_URI;
const GET_IMAGES_BY_LOCATION_URI = `${BASE_URL}/images/location`
const POST_REVIEW_LOCATION_URI = `${BASE_URL}/review/location`
const GET_CURRENT_USER_REVIEW_LOCATION_URI = `${BASE_URL}/user/review/location`
export {
BASE_URL,
SIGNUP_URI,
LOGIN_URI,
LOGOUT_URI,
SIGNUP_URI,
DELETE_USER_AVATAR,
GET_NEWS_EVENTS_URI,
GET_REGIONS,
GET_PROVINCES,
GET_REGENCIES,
GET_CURRENT_USER_STATS,
GET_LIST_RECENT_LOCATIONS_RATING_URI,
GET_LIST_TOP_LOCATIONS,
GET_LIST_LOCATIONS_URI,
GET_LOCATION_URI,
GET_LOCATION_TAGS_URI,
GET_IMAGES_BY_LOCATION_URI,
POST_REVIEW_LOCATION_URI
GET_CURRENT_USER_REVIEW_LOCATION_URI,
PATCH_USER_AVATAR,
PATCH_USER_INFO,
POST_REVIEW_LOCATION_URI,
POST_CREATE_LOCATION,
POST_NEWS_EVENTS_URI,
}

View File

@ -1,2 +1,3 @@
export const DEFAULT_AVATAR_IMG = 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png';
export const DEFAULT_AVATAR_IMG = 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png';
export const DEFAULT_LOCATION_THUMBNAIL_IMG = 'https://i.ytimg.com/vi/0DY1WSk8B9o/maxresdefault.jpg';

View File

@ -0,0 +1,13 @@
import { NullValueRes } from '../types/common';
export type LocationInfo = {
id: Number,
name: string,
thumbnail: NullValueRes<'String', string>,
regency_name: String,
province_name: String,
critic_score: Number,
critic_count: Number,
user_score: Number,
user_count: Number
}

29
src/domains/NewsEvent.ts Normal file
View File

@ -0,0 +1,29 @@
export type News = {
id: number,
title: string,
url: string
source: string
thumbnail: string,
description: string
is_deleted: boolean,
submitted_by: string,
approved_by: number,
created_at: string,
updated_at: string
}
export function emptyNewsEvent(): News {
return {
id: 0,
title: '',
url: '',
source: '',
thumbnail: '',
description: '',
is_deleted: false,
submitted_by: '',
approved_by: 0,
created_at: '',
updated_at: '',
}
}

13
src/domains/Province.ts Normal file
View File

@ -0,0 +1,13 @@
export type Province = {
id: number,
province_name: string,
region_id: number
}
export function emptyProvince(): Province {
return {
id: 0,
province_name: '',
region_id: 0
}
}

13
src/domains/Regency.ts Normal file
View File

@ -0,0 +1,13 @@
export type Regency = {
id: number,
regency_name: string,
province_id: number
}
export function emptyRegency(): Regency {
return {
id: 0,
province_id: 0,
regency_name: ''
}
}

4
src/domains/Region.ts Normal file
View File

@ -0,0 +1,4 @@
export type Region = {
id: number,
region_name: string
}

49
src/domains/User.ts Normal file
View File

@ -0,0 +1,49 @@
import { NullValueRes } from "../types/common";
export interface SocialMedia {
type: string,
value: string
}
export interface UserInfo {
about: string,
website: string,
social_media: Array<SocialMedia>
}
export type User = {
id: number,
email: string,
username: string,
about: string,
website: string,
avatar_picture: string,
banned_at: NullValueRes<"Time", string>,
banned_until: NullValueRes<"Time", string>,
ban_reason: string,
is_permaban: boolean,
is_admin: boolean,
is_critics: boolean,
is_verfied: boolean,
social_media: Array<SocialMedia>
}
export function emptyUser(): User {
return {
avatar_picture: '',
ban_reason: '',
about: '',
website: '',
banned_at: { Time: '', Valid: false},
banned_until: { Time: '', Valid: false},
email: '',
id: 0,
is_admin: false,
is_critics: false,
is_permaban: false,
is_verfied: false,
social_media: Array<SocialMedia>(),
username: ''
}
}

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

@ -0,0 +1,18 @@
import { LocationInfo } from "./LocationInfo"
import { Province, emptyProvince } from "./Province"
import { Regency, emptyRegency } from "./Regency"
import { User } from './User';
import { Region } from "./Region"
export type {
LocationInfo,
Province,
Regency,
Region,
User,
}
export {
emptyProvince,
emptyRegency
}

View File

@ -1,14 +0,0 @@
export interface IUser {
id: Number,
email: String,
username: String,
avatar_picture: String,
banned_at: NullValueRes<"Time", String>,
banned_until: NullValueRes<"Time", String>,
ban_reason: String,
is_permaban: boolean,
is_admin: boolean,
is_critics: boolean,
is_verfied: boolean,
social_media: NullValueRes<"RawMessage", any>
}

View File

@ -0,0 +1,243 @@
import { ChangeEvent, TargetedEvent, useEffect, useState } from "preact/compat";
import { getListRecentLocationsRatingsService, getProvincesService, getRegenciesService } from "../../services";
import { LocationInfo } from "../../domains/LocationInfo";
import { DropdownInput } from "../../components";
import { IndonesiaRegionsInfo, LocationType } from "../../types/common";
import { Regency, emptyRegency } from "../../domains";
import { Form } from './types';
import { enumKeys } from "../../utils";
import './style.css';
import { createLocationService } from "../../services/locations";
import { useSelector } from "react-redux";
import { UserRootState } from "../../store/type";
function AddLocation() {
const [recentLocations, setRecentLocations] = useState<Array<LocationInfo>>()
const [idnRegions, setIdnRegions] = useState<IndonesiaRegionsInfo>({
regencies: Array<Regency>(),
})
const [form, setForm] = useState<Form>({
name: '',
address: '',
google_maps_link: '',
location_type: LocationType.Beach,
regency: emptyRegency(),
thumbnails: [],
})
const user = useSelector((state: UserRootState) => state.auth)
const [pageState, setPageState] = useState({
regency_form_error: false,
})
async function getRecentLocations() {
try {
const locations = await getListRecentLocationsRatingsService(9)
setRecentLocations(locations.data)
// setIsLoading(false)
} catch (error) {
console.log(error)
}
}
async function getRegions() {
try {
const provinces = await getProvincesService();
const regencies = await getRegenciesService();
setIdnRegions({
provinces: provinces.data,
regencies: regencies.data
})
} catch (error) {
console.log(error)
}
}
async function onSubmitForm(e: TargetedEvent) {
e.preventDefault();
if(form.regency.regency_name === '') {
setPageState({ regency_form_error: true })
return
}
let tempThumbnailArr: Array<File> = [];
let formData = new FormData();
form.thumbnails.forEach(x => {
tempThumbnailArr.push(x.file)
})
formData.append("address", form.address);
formData.append("location_type", form.location_type);
formData.append("name", form.name);
formData.append("regency_id", form.regency.id.toString());
formData.append("submitted_by", user.id.toString());
formData.append("google_maps_link", form.google_maps_link);
for(let i = 0; i < tempThumbnailArr.length; i++) {
formData.append("thumbnail", tempThumbnailArr[i])
}
try {
await createLocationService(formData)
alert("Location Added")
} catch(error) {
console.log(error)
}
}
function onChangeUploadImage(e: TargetedEvent) {
e.preventDefault()
let event = e.target as HTMLInputElement;
const files = Array.from(event.files as ArrayLike<File>);
const result = files.filter((x) => {
if (x.type === "image/jpg" || x.type === "image/png" || x.type === "image/jpeg") {
return true
}
return false
}).map(v => {
let ret = {
file: v,
url: URL.createObjectURL(v)
}
return ret
})
setForm({ ...form, thumbnails: result })
}
function onDeleteSelectedThumbnail(e: TargetedEvent, idx: number) {
e.preventDefault();
// remove image from FileList
const dt = new DataTransfer();
const tempInput = document.getElementById('imageUpload')!;
const input = tempInput as HTMLInputElement;
const { files } = input;
for (let i = 0; i < files!.length; i++) {
const file = files![i]
if (idx !== i)
dt.items.add(file)
}
input.files = dt.files
let thumbnails = form.thumbnails.filter((_, index) => index != idx);
setForm({ ...form, thumbnails: thumbnails })
}
function onChangeFormInput(e: ChangeEvent) {
let event = e.target as HTMLInputElement;
setForm({ ...form, [event.name]: event.value })
}
function onDeleteAllThumbnails(e: TargetedEvent) {
e.preventDefault();
const dt = new DataTransfer();
const tempInput = document.getElementById('imageUpload');
const input = tempInput as HTMLInputElement;
dt.items.clear();
input.files = dt.files
setForm({ ...form, thumbnails: [] })
}
function onChangeRegencyDropdownInput(val: Regency) {
setPageState({ ...pageState, regency_form_error: false })
setForm({ ...form, regency: val })
}
useEffect(() => {
getRecentLocations()
getRegions()
}, [])
return (
<div className={'content main-content'}>
<div style={{ tableLayout: 'fixed', display: 'table', width: '100%' }}>
<div style={{ textAlign: 'left', padding: '20px 30px', maxWidth: 1096, minWidth: 680, display: 'table-cell', position: 'relative', verticalAlign: 'top', width: '100%' }}>
<h1 className={'text-3xl mb-5 font-bold'}>Add New Location</h1>
<div style={{ backgroundColor: '#2f3136', padding: 10, margin: '0 0 25px' }}>
<form onSubmit={onSubmitForm}>
<span className={'block mt-2 text-sm mb-2'}>Location Name <span className={'text-error'}>*</span></span>
<input className={'bg-primary text-sm input-text'} type={'text'} onChange={onChangeFormInput} name={'name'} value={form.name} required />
<span className={'block mt-2 text-sm mb-2'}>Address <span className={'text-error'}>*</span></span>
<input className={'bg-primary text-sm input-text'} type={'text'} onChange={onChangeFormInput} name={'address'} value={form.address} required />
<label className={'block text-sm mt-2 mb-2'} for="location_type">Location Category</label>
<select className={'bg-primary p-1'} name="location_type" id="location_type">
{ enumKeys(LocationType).map(x => (
<option value={LocationType[x]}>{LocationType[x]}</option>
))
}
</select>
<span className={'block mt-2 text-sm mb-2'}>Kota / Kabupaten <span className={'text-error'}>*</span> <span className={`text-xs text-error ${!pageState.regency_form_error && 'hidden'}`}> (regency mustn't be empty)</span></span>
<DropdownInput
isSearchable={true}
onChange={(val: Regency) => onChangeRegencyDropdownInput(val)}
labelPropsName={"regency_name"}
options={idnRegions.regencies!}
placeholder={''}
/>
<span className={'block mt-2 text-sm mb-2'}>Google Maps Link <span className={'text-error'}>*</span></span>
<input className={'bg-primary text-sm input-text'} type={'text'} onChange={onChangeFormInput} name={'google_maps_link'} value={form.google_maps_link} required />
<span className={'block mt-2 text-sm mb-2'}>Thumbnails</span>
<input id={'imageUpload'} className={'inputfile'} type="file" accept={'image/*'} multiple onChange={onChangeUploadImage} />
<label for={'imageUpload'}>Choose File(s)</label>
<div style={{ clear: 'both' }} />
{form.thumbnails.length > 0 &&
form.thumbnails.map((x, idx) => (
<div className={'mr-3 font-bold'} style={{ width: 150, display: 'inline-block', textAlign: 'right', fontSize: 20 }}>
<a onClick={(e) => onDeleteSelectedThumbnail(e, idx)}>
x
</a>
<img
loading={'lazy'}
src={x.url}
className={'mt-1'}
style={{ aspectRatio: '1/1', objectFit: 'cover' }}
/>
</div>
))
}
{form.thumbnails.length > 0 &&
<a className={'block mt-2'} onClick={onDeleteAllThumbnails}>Delete All Thumbnails</a>
}
<input type={'submit'} value={'Submit'} className={'block p-1 text-sm text-primary mt-4'} style={{ backgroundColor: '#a8adb3', letterSpacing: .5, width: 75 }} />
</form>
<span className={'mt-5 text-sm font-bold block'}>NOTE: LOCATION SUBMISSION MAY BE EDITED BY MODERATOR SO DON'T PUT STUPID ASS THUMBNAILS YOU 1 CENT DOORKNOB</span>
</div>
</div>
<div style={{ display: 'table-cell', position: 'relative', verticalAlign: 'top', width: 345, textAlign: 'left', padding: '15px 0' }}>
<p>Recently added locations</p>
{recentLocations?.map(x => (
<div style={{ width: '32%', display: 'inline-block', padding: '1px 1%' }}>
<a href={'#'} title={x.name}>
<img
loading={'lazy'}
src={x.thumbnail.String.toString()}
alt={x.name}
style={{ aspectRatio: '1/1' }}
/>
</a>
</div>
))}
</div>
</div>
</div>
)
}
export default AddLocation;

View File

@ -0,0 +1,33 @@
.input-text {
border-radius: 7px;
min-width: 325px;
max-width: 450px;
width: 100%;
padding: 5px 10px;
}
.inputfile {
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
position: absolute;
z-index: -1;
}
.inputfile + label {
font-size: 14px;
color: white;
display: inline-block;
padding: 5px;
background-color: #555555;
border-radius: 10px;
}
.inputfile + label:hover {
background-color: #202225;
}
.inputfile + label {
cursor: pointer; /* "hand" cursor */
}

View File

@ -0,0 +1,16 @@
import { LocationType } from "src/types/common"
import { Regency } from "../../domains"
export interface Thumbnail {
file: File,
url: string
}
export interface Form {
name: string,
address: string,
regency: Regency,
location_type: LocationType,
google_maps_link: string,
thumbnails: Array<Thumbnail>
}

View File

@ -1,7 +1,8 @@
import { TargetedEvent } from "preact/compat";
import { useEffect, useState } from "preact/hooks";
import { getListTopLocationsService } from "../../services";
import { DefaultSeparator } from "../../components";
import { TargetedEvent } from "preact/compat";
import { NullValueRes } from "../../types/common";
import './style.css';
interface TopLocation {
@ -57,7 +58,11 @@ function BestLocation() {
async function getTopLocations() {
try {
const res = await getListTopLocationsService({ page: page, page_size: 20, order_by: pageState.filterScoreTypeidx, region_type: pageState.filterRegionType })
const res = await getListTopLocationsService({
page: page, page_size: 20,
order_by: pageState.filterScoreTypeidx,
region_type: pageState.filterRegionType
})
setTopLocations(res.data)
} catch (err) {

View File

@ -1,24 +1,12 @@
import { SeparatorWithAnchor } from '../../components';
import news from '../../datas/recent_news_event.json';
import popular from '../../datas/popular.json';
import critics_users_pick from '../../datas/critics_users_best_pick.json';
import popular_user_review from '../../datas/popular_user_reviews.json';
import './style.css';
import { useEffect, useState } from 'preact/hooks';
import { getListRecentLocationsRatingsService } from '../../services';
import { getListRecentLocationsRatingsService, getListTopLocationsService } from '../../services';
import { useNavigate } from 'react-router-dom';
type NewPlaces = {
id: Number,
name: string,
thumbnail: NullValueRes<'String', string>,
regency_name: String,
province_name: String,
critic_score: Number,
critic_count: Number,
user_score: Number,
user_count: Number
}
import { LocationInfo } from '../../domains/LocationInfo';
type News = {
header: string,
@ -29,7 +17,9 @@ type News = {
}
function Home() {
const [recentLocations, setRecentLocations] = useState<Array<NewPlaces>>([])
const [recentLocations, setRecentLocations] = useState<Array<LocationInfo>>([])
const [topCriticsLocations, setTopCriticsLocations] = useState<Array<LocationInfo>>([])
const [topUsersLocations, setTopUsersLocations] = useState<Array<LocationInfo>>([])
// const [isLoading, setIsLoading] = useState<boolean>(true)
const navigate = useNavigate()
@ -45,6 +35,24 @@ function Home() {
}
}
async function getCrititsBestLocations() {
try {
const res = await getListTopLocationsService({ page: 1, page_size: 6, order_by: 2, region_type: 0})
setTopCriticsLocations(res.data)
}catch(err) {
console.log(err)
}
}
async function getUsersBestLocations() {
try {
const res = await getListTopLocationsService({ page: 1, page_size: 6, order_by: 3, region_type: 0})
setTopUsersLocations(res.data)
}catch(err) {
console.log(err)
}
}
function onNavigateToDetail(
id: Number,
critic_count: Number,
@ -59,6 +67,8 @@ function Home() {
useEffect(() => {
getRecentLocations()
getCrititsBestLocations()
getUsersBestLocations()
},[])
return (
<>
@ -185,17 +195,21 @@ function Home() {
</div>
<div className={'col-span-2 lg:col-span-1'}>
<SeparatorWithAnchor pageLink='#' pageName={"Critic's Best"} />
{critics_users_pick.critics.map((x) => (
<div className={"pt-2 text-sm"}>
{topCriticsLocations.map((x) => (
<div className={"pt-2 text-sm top-location-container"}>
<div className={'mr-2 critics-users-image'}>
<img src={x.thumbnail} loading={'lazy'} style={{ height: '100%', width: '100%', borderRadius: 3}} />
<img
src={x.thumbnail.Valid ? x.thumbnail.String.toString() : 'https://i.ytimg.com/vi/0DY1WSk8B9o/maxresdefault.jpg'}
loading={'lazy'}
style={{ height: '100%', width: '100%', borderRadius: 3}}
/>
</div>
<p className={'location-title'}>{x.name}</p>
<p className={'text-xs location-province location-title'}>{x.location}</p>
<p className={'text-xs location-province location-title'}>{x.regency_name}</p>
<div className={'critics-users-rating-container'} style={{ display: 'inline-block' }}>
<p className={'text-xs ml-2'}>{x.critic_rating}</p>
<p className={'text-xs ml-2'}>{x.critic_score} <span className={'text-gray'}>({x.critic_count})</span></p>
<div style={{ height: 4, width: 30, backgroundColor: "#72767d" }}>
<div style={{ height: 4, width: `${x.critic_rating}%`, backgroundColor: 'green' }} />
<div style={{ height: 4, width: `${x.critic_score}%`, backgroundColor: 'green' }} />
</div>
</div>
<div style={{ clear: 'both'}} />
@ -205,17 +219,20 @@ function Home() {
</div>
<div className={'col-span-2 lg:col-span-1'}>
<SeparatorWithAnchor pageLink='#' pageName={"User's Best"} />
{critics_users_pick.users.map((x) => (
<div className={"pt-2 text-sm"}>
{topUsersLocations.map((x) => (
<div className={"pt-2 text-sm top-location-container"}>
<div className={'mr-2 critics-users-image'}>
<img src={x.thumbnail} style={{ height: '100%', width: '100%', borderRadius: 3}} />
<img
src={x.thumbnail.Valid ? x.thumbnail.String.toString() : 'https://i.ytimg.com/vi/0DY1WSk8B9o/maxresdefault.jpg'}
style={{ height: '100%', width: '100%', borderRadius: 3}}
/>
</div>
<p className={'location-title'}>{x.name}</p>
<p className={'text-xs location-province location-title'}>{x.location}</p>
<p className={'text-xs location-province location-title'}>{x.regency_name}</p>
<div className={'critics-users-rating-container'} style={{ display: 'inline-block' }}>
<p className={'text-xs ml-2'}>{x.user_rating}</p>
<p className={'text-xs ml-2'}>{x.user_score} <span className={'text-xs text-gray'}>({x.user_count})</span></p>
<div style={{ height: 4, width: 30, backgroundColor: "#72767d"}}>
<div style={{ height: 4, width: `${x.user_rating}%`, backgroundColor: 'green' }} />
<div style={{ height: 4, width: `${x.user_score}%`, backgroundColor: 'green' }} />
</div>
</div>
<div style={{ clear: 'both'}} />

View File

@ -152,7 +152,6 @@
.news-card {
width: 100%;
}
.news-link {
font-size: 0.75rem;
line-height: 1rem;
@ -178,4 +177,8 @@
height: 100px;
}
.top-location-container .location-title {
font-size: 0.685rem;
}
}

View File

@ -86,6 +86,18 @@ img {
cursor: pointer;
}
.review-box-content {
width: 75%;
margin: 0 auto;
}
@media screen and (max-width: 1024px) {
.review-box-content {
width: 100%;
}
}
@media screen and (max-width: 380px) {
.header-link {
white-space: nowrap;

View File

@ -10,14 +10,16 @@ import {
emptyLocationResponse,
CurrentUserLocationReviews,
} from './types';
import { AxiosError } from 'axios';
import { handleAxiosError, useAutosizeTextArea } from '../../utils';
import { getImagesByLocationService, getLocationService, postReviewLocation } from "../../services";
import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation } from "../../services";
import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading } from '../../components';
import { useSelector } from 'react-redux';
import { UserRootState } from '../../store/type';
import { DEFAULT_AVATAR_IMG } from '../../constants/default';
import './index.css';
import { AxiosError } from 'axios';
import { IHttpResponse } from '../../types/common';
import ReactTextareaAutosize from 'react-textarea-autosize';
const SORT_TYPE = [
'highest rated',
@ -31,6 +33,7 @@ function LocationDetail() {
const [locationImages, setLocationImages] = useState<LocationResponse>(emptyLocationResponse())
const [currentUserReview, setCurrentUserReview] = useState<CurrentUserLocationReviews>()
const [lightboxOpen, setLightboxOpen] = useState<boolean>(false)
const[updatePage, setUpdatePage] = useState<boolean>(true)
const [pageState, setPageState] = useState({
critic_filter_name: 'highest rated',
critic_filter_type: 0,
@ -74,12 +77,27 @@ function LocationDetail() {
const res = await getImagesByLocationService({ page: 1, page_size: 15, location_id: Number(id) })
res.data.images.push({ src: thumbnail })
setLocationImages(res.data)
setUpdatePage(false)
} catch (error) {
console.log(error)
}
setIsLoading(false)
}
async function getCurrentUserLocationReview(): Promise<void> {
try {
const res = await getCurrentUserLocationReviewService(Number(id))
setCurrentUserReview(res.data)
setPageState({ ...pageState, enable_post: false})
} catch (error) {
let err = error as IHttpResponse;
if(err.status == 404 || err.status == 401 ) {
return
}
alert(err.error.response.data.message)
}
}
function handleTextAreaChange(e: ChangeEvent<HTMLTextAreaElement>): void {
const val = e.target as HTMLTextAreaElement;
@ -150,6 +168,7 @@ function LocationDetail() {
created_at: data.created_at,
updated_at: data.updated_at
})
setUpdatePage(true)
} catch (error) {
let err = error as AxiosError;
console.log(err)
@ -168,8 +187,14 @@ function LocationDetail() {
}
useEffect(() => {
getLocationDetail()
getCurrentUserLocationReview()
}, [])
useEffect(() => {
if(updatePage) {
getLocationDetail()
}
}, [updatePage])
return (
<>
@ -300,7 +325,7 @@ function LocationDetail() {
<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={'reviewBoxContent'} style={{ width: '75%', margin: '0 auto' }}>
<div className={'review-box-content'}>
<div className={'userImage mr-3'} style={{ width: 55, float: 'left' }}>
<a href={'#'}>
@ -315,7 +340,6 @@ function LocationDetail() {
<div className={'ratingInput'} style={currentUserReview ? { margin: '0 0 10px' } : { margin: '5px 0 10px' }}>
{currentUserReview ?
<div style={{ display: 'inline-block' }}>
{console.log(currentUserReview)}
<p className={'ml-2'}>{currentUserReview.score}</p>
<div style={{ height: 4, width: 35, backgroundColor: "#72767d" }}>
<div style={{ height: 4, width: `${currentUserReview.score}%`, backgroundColor: 'green' }} />
@ -349,12 +373,11 @@ function LocationDetail() {
content={currentUserReview.comments}
/>
:
<textarea
<ReactTextareaAutosize
onChange={handleTextAreaChange}
ref={textAreaRef}
className={'p-2'}
className={'p-2 text-area text-sm'}
value={reviewValue.review_textArea}
style={{ border: 'none', overflow: 'auto', outline: 'none', boxShadow: 'none', backgroundColor: '#40444b', width: '100%', minHeight: 100, overflowY: 'hidden' }}
/>
}
</div>
@ -379,67 +402,77 @@ function LocationDetail() {
}
<div name={'CRTICITS REVIEW'} style={{ margin: '50px 0', textAlign: 'left' }}>
<SeparatorWithAnchor pageName={"critic's review"} pageLink='#' />
<div className={'criticSortFilter'}>
<div className={'inline-block text-sm'}>Sort by: </div>
<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>
<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>
</a>
<div className={'dropdown-content text-sm bg-secondary'} style={pageState.show_sort ? { display: 'block' } : ''}>
{SORT_TYPE.map((x, index) => (
<a onClick={(e) => onChangeCriticsSort(e, x, index)} className={'block pt-1 capitalize'}>{x}</a>
))}
</div>
</div>
<div style={{ clear: 'both' }} />
{locationDetail.critics_review.map(x => (
<div className={''} style={{ padding: '15px 0' }}>
<div style={{ float: 'left' }}>
<div style={{ fontSize: 20, marginRight: 20, textAlign: 'center', width: 55, marginBottom: 3 }}>
{x.score}
</div>
<div style={{ height: 4, width: 55, position: 'relative', backgroundColor: '#d8d8d8' }}>
<div style={{ height: 4, backgroundColor: '#85ce73', width: `${x.score}%` }} />
</div>
</div>
<div className={'mr-3'} style={{ display: 'inline-block', width: 40 }}>
<a href="#">
<img
loading={'lazy'}
style={{ width: '100%' }}
src={x.user_avatar.Valid ? x.user_avatar.String.toString() : 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'}
/>
{locationDetail.critics_review.length > 0 ?
<>
<div className={'criticSortFilter'}>
<div className={'inline-block text-sm'}>Sort by: </div>
<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>
<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>
</a>
</div>
<div style={{ display: 'inline-block', verticalAlign: 'top' }}>
<div style={{ fontWeight: 700, fontSize: 16, lineHeight: 'initial' }}>
<a>
<span>{x.username}</span>
</a>
<div className={'dropdown-content text-sm bg-secondary'} style={pageState.show_sort ? { display: 'block' } : ''}>
{SORT_TYPE.map((x, index) => (
<a onClick={(e) => onChangeCriticsSort(e, x, index)} className={'block pt-1 capitalize'}>{x}</a>
))}
</div>
</div>
<div style={{ fontSize: 15, lineHeight: '24px', margin: '5px 75px 1px' }}>
<CustomInterweave
content={x.comments}
/>
</div>
<div className={'reviewLinks'} style={{ marginLeft: 72 }}>
<div className={'mr-2'} style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle' }}>
<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>
<div className={'inline-block'}>Video</div>
</a>
<div style={{ clear: 'both' }} />
{locationDetail.critics_review.map(x => (
<div className={''} style={{ padding: '15px 0' }}>
<div style={{ float: 'left' }}>
<div style={{ fontSize: 20, marginRight: 20, textAlign: 'center', width: 55, marginBottom: 3 }}>
{x.score}
</div>
<div style={{ height: 4, width: 55, position: 'relative', backgroundColor: '#d8d8d8' }}>
<div style={{ height: 4, backgroundColor: '#85ce73', width: `${x.score}%` }} />
</div>
</div>
<div className={'mr-3'} style={{ display: 'inline-block', width: 40 }}>
<a href="#">
<img
loading={'lazy'}
style={{ width: '100%' }}
src={x.user_avatar.Valid ? x.user_avatar.String.toString() : 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'}
/>
</a>
</div>
<div style={{ display: 'inline-block', verticalAlign: 'top' }}>
<div style={{ fontWeight: 700, fontSize: 16, lineHeight: 'initial' }}>
<a>
<span>{x.username}</span>
</a>
</div>
</div>
<div style={{ fontSize: 15, lineHeight: '24px', margin: '5px 75px 1px' }}>
<CustomInterweave
content={x.comments}
/>
</div>
<div className={'reviewLinks'} style={{ marginLeft: 72 }}>
<div className={'mr-2'} style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle' }}>
<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>
<div className={'inline-block'}>Video</div>
</a>
</div>
<div style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle' }}>
<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>
<div className={'inline-block'}>Instagram</div>
</a>
</div>
</div>
</div>
<div style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle' }}>
<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>
<div className={'inline-block'}>Instagram</div>
</a>
</div>
</div>
</div>
))}
))}
</>
:
<>
<span className={'text-sm italic'}>No Critics review to display</span>
</>
}
</div>
<div name={'USERS REVIEW'} style={{ margin: '50px 0', textAlign: 'left' }}>
@ -605,6 +638,8 @@ function LocationDetail() {
</div>
{/* {screen.width >= 1024 &&
<div className={'bg-secondary'} style={{ display: 'table-cell', position: 'relative', verticalAlign: 'top', width: 330, textAlign: 'left', padding: 15, boxSizing: 'border-box', height: 1080 }}>
// ADD USER DISTRIBUTION SOMETHING LIKE THIS
// https://www.w3schools.com/howto/tryit.asp?filename=tryhow_css_user_rating
Lorem ipsum dolor sit amet consectetur adipisicing elit. Reprehenderit cumque aliquam doloribus in reiciendis? Laborum, ea assumenda, tempora dolore placeat aspernatur, cumque totam sequi debitis dolor nam eligendi suscipit aliquid?
</div>
} */}

View File

@ -1,3 +1,4 @@
import { NullValueRes } from "../../types/common"
import { SlideImage } from "yet-another-react-lightbox"
export interface ILocationDetail {

View File

@ -1,4 +1,4 @@
input {
td input {
padding: 5px;
font-size: 14px;
}
}

View File

@ -24,8 +24,14 @@ function Login() {
e.preventDefault();
try {
const res = await loginService({ username: form.username, password: form.password })
if (res.error) {
setErrorMsg(res.error.response.data.errors)
if(res.error) {
if (res.error.response.status == 400) {
setErrorMsg(res.error.response.data.errors)
return
}
alert(res.error.response.data.message)
return
}
@ -72,7 +78,6 @@ function Login() {
function onChangeInput(e: ChangeEvent<HTMLInputElement>) {
const val = e.target as HTMLInputElement;
setFrom({ ...form, [val.placeholder]: val.value })
}
return (
@ -86,11 +91,14 @@ function Login() {
<td><input value={form.username} onChange={onChangeInput} placeholder={'username'} className={'bg-secondary'} /></td>
</tr>
<tr>
<td>password: </td>
<td>password: </td>
<td><input value={form.password} onChange={onChangeInput} type={'password'} placeholder={'password'} className={'bg-secondary'} /></td>
</tr>
</tbody>
</table>
{errorMsg.map(x => (
<p>{x.msg}</p>
))}
<a className={'block'}>Forgout account ?</a>
<input type={'submit'} value={'sign in'} className={'p-1 text-sm text-primary mt-4'} style={{ backgroundColor: '#a8adb3', borderRadius: 7 }} />
</form>

View File

@ -0,0 +1,241 @@
import { ChangeEvent, TargetedEvent, useEffect, useRef, useState } from "preact/compat";
import { DefaultButton, NavigationSeparator } from "../../components";
import { News } from "../../domains/NewsEvent";
import { getNewsServices, postNewsService } from "../../../src/services";
import { AxiosError } from "axios";
import { DEFAULT_LOCATION_THUMBNAIL_IMG } from "../../../src/constants/default";
import { isUrl, useAutosizeTextArea } from "../../../src/utils";
import ReactTextareaAutosize from "react-textarea-autosize";
import { IHttpResponse } from "../../../src/types/common";
import { useSelector } from "react-redux";
import { UserRootState } from "src/store/type";
import "./style.css"
import useCallbackState from "../../../src/types/state-callback";
function NewsEvent() {
const [news, setNews] = useState<Array<News>>([]);
const [isLoading, setIsLoading] = useState(false);
const [pageState, setPageState] = useState({
news_category_name: "hot",
page: 1,
})
const user = useSelector((state: UserRootState) => state.auth)
const [form, setForm] = useState({
description: '',
url: '',
title: '',
title_error_msg: '',
link_error_msg: '',
})
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useAutosizeTextArea(textAreaRef.current, form.description);
async function getNewsEvents(approved: number = 1) {
try {
const news = await getNewsServices({ page: pageState.page, page_size: 10, is_with_approval: approved})
if (news.status == 200) {
setNews(news.data)
}
console.log(news)
} catch (error) {
let err = error as AxiosError;
if (!err.status) {
alert('Server is in trouble, probably dead RIP');
}
console.log(err)
}
}
function handleTextAreaChange(e: ChangeEvent<HTMLTextAreaElement>): void {
const val = e.target as HTMLTextAreaElement;
setForm({
...form,
description: val.value
})
}
function handleInputChange(e: ChangeEvent): void {
const target = e.target as HTMLInputElement;
setForm({
...form,
[target.name]: target.value,
link_error_msg: '',
title_error_msg: ''
})
}
function isFormEmpty() {
const { url, title} = form;
let url_error = ''
let title_error = ''
if(url == '') url_error = "url mustn't empty"
if(title == '') title_error = "title mustn't empty"
return {url_error, title_error}
}
function handleOnChangeNewsCategory(e: TargetedEvent, name: string) {
e.preventDefault();
if(name.toLowerCase() === "fresh") {
setPageState({ ...pageState, news_category_name: "fresh" })
getNewsEvents(0)
return
}
setPageState({ ...pageState, news_category_name: "hot" })
getNewsEvents(1)
}
async function handleSumbitNews(e: TargetedEvent) {
e.preventDefault();
setIsLoading(true)
const {url_error, title_error } = isFormEmpty()
if(url_error !== "" || title_error !== "") {
setForm({ ...form, link_error_msg: url_error, title_error_msg: title_error})
setIsLoading(false)
return
}
if(!isUrl(form.url)) {
setForm({ ...form, link_error_msg: 'url is not correct(https://validurl.com")'})
setIsLoading(false)
return
}
try {
const response = await postNewsService({
description: form.description,
submitted_by: user.id,
title: form.title,
url: form.url
})
if(response.status == 201) {
alert("success")
setForm({
link_error_msg: '',
title_error_msg: '',
title: '',
description: '',
url: '',
})
}
setIsLoading(false)
} catch(error) {
setIsLoading(false)
let err = error as IHttpResponse;
console.log(err)
}
}
useEffect(() => {
getNewsEvents()
}, [])
return (
<div className={'content main-content'}>
<h1 className={'text-2xl mb-5 font-bold'}>News / Events</h1>
<div style={{ maxWidth: 1003 }}>
<NavigationSeparator
pageNames={['Hot', 'Fresh']}
onClick={handleOnChangeNewsCategory}
selectedValue={pageState.news_category_name}
/>
<section name="news content">
{news.map(x => (
<div style={{ marginBottom: 15, paddingBottom: 15, borderBottom: '1px solid #38444d', marginTop: 30 }}>
<a href={x.url} rel={"nofollow"} target={"_"}>
<div style={{ float: 'left', marginRight: 20, width: 200 }}>
<img
src={x.thumbnail != '' ? x.thumbnail : DEFAULT_LOCATION_THUMBNAIL_IMG}
style={{ width: '100%' }}
/>
</div>
</a>
<div style={{ position: 'relative', overflow: 'hidden' }}>
<div className={'text-2xl font-bold'}>
<a href={x.url} target={"_"} rel={"nofollow"}>{x.title}</a>
</div>
<div className={'text-xs mt-1'}>
<div className={'inline-block text-yellow font-bold'}>
{x.source}
</div>
<div className={'submitted-info'} style={{ display: 'inline-block' }}>
1d ago by
<a> {x.submitted_by}</a>
</div>
</div>
<div className={'text-xs text-gray mt-6'}>
<a className={'inline-block'}>
<svg className={'inline-block'} xmlns="http://www.w3.org/2000/svg" fill={"gray"} height="18" viewBox="0 -960 960 960" width="18"><path d="m480-120-58-52q-101-91-167-157T150-447.5Q111-500 95.5-544T80-634q0-94 63-157t157-63q52 0 99 22t81 62q34-40 81-62t99-22q94 0 157 63t63 157q0 46-15.5 90T810-447.5Q771-395 705-329T538-172l-58 52Zm0-108q96-86 158-147.5t98-107q36-45.5 50-81t14-70.5q0-60-40-100t-100-40q-47 0-87 26.5T518-680h-76q-15-41-55-67.5T300-774q-60 0-100 40t-40 100q0 35 14 70.5t50 81q36 45.5 98 107T480-228Zm0-273Z" /></svg>
<p className={"mr-3 ml-1 inline-block"}>10</p>
</a>
<a className={'inline-block'}>
<svg className={'inline-block'} style={{ marginTop: 2 }} stroke="currentColor" fill="white" stroke-width="0" viewBox="0 0 512 512" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M256 32C114.6 32 0 125.1 0 240c0 49.6 21.4 95 57 130.7C44.5 421.1 2.7 466 2.2 466.5c-2.2 2.3-2.8 5.7-1.5 8.7S4.8 480 8 480c66.3 0 116-31.8 140.6-51.4 32.7 12.3 69 19.4 107.4 19.4 141.4 0 256-93.1 256-208S397.4 32 256 32zM128 272c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 0c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 0c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"></path></svg>
<p className={"ml-1 inline-block"}>12</p>
</a>
</div>
</div>
<div style={{ clear: 'both' }} />
</div>
))}
</section>
<section name={"news input"}>
<div className={'bg-secondary'} style={{ maxWidth: '100%', padding: '10px 10px'}}>
<h1>SUBMIT A NEWS / EVENT</h1>
<h2 className={'mt-3'}>Title</h2>
<input
className={'input-text'}
style={{ background: '#40444b'}}
maxLength={150}
name={'title'}
placeholder={'Konser besar akbar agung di bengkulu'}
onChange={handleInputChange}
value={form.title}
/>
<span className={'text-xs ml-3 text-error'}>{form.title_error_msg}</span>
<h2 className={'mt-3'}>Link</h2>
<input
className={'input-text'}
style={{ background: '#40444b'}}
name={'url'}
value={form.url}
placeholder={'https://tourismnews.com/12'}
onChange={handleInputChange}
/>
<span className={'text-xs ml-3 text-error'}>{form.link_error_msg}</span>
<h2 className={'mt-3'}>Description (optional) </h2>
<ReactTextareaAutosize
class="text-area p-2"
onChange={handleTextAreaChange}
value={form.description}
/>
<DefaultButton
label="submit"
containerClassName="mt-5"
containerStyle={{ float: 'right'}}
style={{ padding: '5px 10px', letterSpacing: 0}}
isLoading={isLoading}
onClick={handleSumbitNews}
/>
<div style={{ clear: 'both'}} />
</div>
</section>
</div>
</div>
)
}
export default NewsEvent;

View File

@ -0,0 +1,4 @@
.submitted-info::before {
content: " // ";
margin-left: 5px;
}

View File

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

View File

@ -0,0 +1,9 @@
function Submissions() {
return (
<div className={'content main-content'}>
<h1>Submssions</h1>
</div>
)
}
export default Submissions;

View File

@ -0,0 +1,9 @@
function UserFeed() {
return (
<div>
<h1>User feed</h1>
</div>
)
}
export default UserFeed;

View File

@ -0,0 +1,226 @@
import { useSelector } from "react-redux";
import { DEFAULT_AVATAR_IMG } from "../../constants/default";
import { UserRootState } from "../../store/type";
import "./style.css"
import { useEffect, useState } from "preact/compat";
import { getUserStatsService } from "../../services";
import { CustomInterweave, SeparatorWithAnchor } from "../../components";
interface UserStats {
followers: number,
score_count: number
}
interface UserStatsReviews {
id: number,
comments: string,
name: string,
province_name: string,
score: number,
thumbnail: string
}
interface ScoresDistrbution {
score: number
}
interface UserStatsResponse {
reviews: Array<UserStatsReviews>,
user_stats: UserStats,
scores_distribution: Array<ScoresDistrbution>
}
const emptyUserStatsResponse: UserStatsResponse = {
reviews: Array<UserStatsReviews>(),
user_stats: {
followers: 0,
score_count: 0
},
scores_distribution: [
{
score: 0
}
]
}
function UserProfile() {
const [userStats, setUserStats] = useState<UserStatsResponse>(emptyUserStatsResponse);
const user = useSelector((state: UserRootState) => state.auth)
async function getUserStats() {
try {
const res = await getUserStatsService()
setUserStats(res.data)
} catch (err) {
console.log(err)
}
}
function scoreDistributionLabel(val: string) {
if (val === '0') {
return `${val}-9`
}
if (val === '99') {
return '100'
}
return `${val}0-${val}9`
}
useEffect(() => {
getUserStats()
}, [])
return (
<div className={'content main-content'}>
<div name={'profile_header'} className={'flex column items-end mb-4'}>
<div className={'flex-1'}>
<img
src={user.avatar_picture !== '' ? user.avatar_picture : DEFAULT_AVATAR_IMG}
style={{ width: 140, aspectRatio: '1/1', float: 'left' }}
className={'mr-4'}
/>
<p className={'text-lg'}>{user.username}</p>
{/* <div className={'mt-2'}>
<button className={'bg-tertiary text-xs mr-2'} style={{ padding: '5px 15px', letterSpacing: .8}}>
FOLLOW
</button>
<button className={'bg-error text-xs'} style={{ padding: '5px 15px', letterSpacing: .8}}>
REPORT
</button>
</div> */}
</div>
<div className={'inline-block mr-4 text-center'}>
<p className={'font-bold'} style={{ fontSize: 32 }}>{userStats?.user_stats.followers}</p>
<p className={'text-xs'}>Followers</p>
</div>
<div className={'inline-block text-center'}>
<p className={'font-bold'} style={{ fontSize: 32 }}>{userStats?.user_stats.score_count}</p>
<p className={'text-xs'}>Reviews</p>
</div>
</div>
<section name={"profile-navigation"}>
<div className={'bg-secondary profile-nav'}>
<a>
<div>
summary
</div>
</a>
<a>
<div>
reviews
</div>
</a>
<a>
<div>
likes
</div>
</a>
<a>
<div>
stories
</div>
</a>
<a>
<div>
tags
</div>
</a>
</div>
</section>
<section name={'REVIEWS SECTION'}>
<div className={'mt-4'} style={{ tableLayout: 'fixed', display: 'table', width: '100%' }}>
<div className={'wideLeft'} style={{ textAlign: 'left', paddingRight: 20, maxWidth: 1096, minWidth: 680, display: 'table-cell', position: 'relative', verticalAlign: 'top', width: '100%', boxSizing: 'border-box' }}>
<div name={'USERS REVIEW'} style={{ textAlign: 'left' }}>
<SeparatorWithAnchor pageName={"User's review"} pageLink='#' secondLink={userStats!.reviews.length > 0 ? '#' : ''} />
{userStats!.reviews.length > 0 ?
<>
{userStats!.reviews.map(x => (
<div style={{ padding: '15px 0' }}>
<div className={'mr-3'} style={{ width: 65, float: 'left' }}>
<a href="#">
<img
loading={'lazy'}
style={{ width: '100%', aspectRatio: '1/1', objectFit: 'cover' }}
src={x.thumbnail !== '' ? x.thumbnail : 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'}
/>
</a>
</div>
<div>
<div style={{ fontWeight: 700, fontSize: 16, lineHeight: 'initial' }}>
<a>
<span>{x.name}</span>
</a>
</div>
</div>
<p className={'text-xs'}>{x.province_name}</p>
<div className={'inline-block'}>
<div className={'text-sm text-center'} >{x.score}</div>
<div style={{ height: 4, width: 25, position: 'relative', backgroundColor: '#d8d8d8' }}>
<div style={{ height: 4, backgroundColor: '#85ce73', width: `${x.score}%` }} />
</div>
</div>
<div style={{ fontSize: 15, lineHeight: '24px', margin: '10px 77px 1px', wordWrap: 'break-word' }}>
<CustomInterweave
content={x.comments}
/>
</div>
<div className={'reviewLinks'} style={{ marginLeft: 76 }}>
<div className={'mr-2'} style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle' }}>
<a className={'text-sm'} href={'#'}>
<svg className={'inline-block mr-1'} xmlns="http://www.w3.org/2000/svg" fill={"gray"} height="14" viewBox="0 -960 960 960" width="14"><path d="m480-120-58-52q-101-91-167-157T150-447.5Q111-500 95.5-544T80-634q0-94 63-157t157-63q52 0 99 22t81 62q34-40 81-62t99-22q94 0 157 63t63 157q0 46-15.5 90T810-447.5Q771-395 705-329T538-172l-58 52Zm0-108q96-86 158-147.5t98-107q36-45.5 50-81t14-70.5q0-60-40-100t-100-40q-47 0-87 26.5T518-680h-76q-15-41-55-67.5T300-774q-60 0-100 40t-40 100q0 35 14 70.5t50 81q36 45.5 98 107T480-228Zm0-273Z" /></svg>
<div className={'inline-block'}>24</div>
</a>
</div>
<div style={{ minWidth: 55, display: 'inline-block', verticalAlign: 'middle', }}>
<a className={'text-sm'} href={'#'}>
<svg className={'inline-block mr-1'} stroke="currentColor" fill="white" stroke-width="0" viewBox="0 0 512 512" height="14" width="14" xmlns="http://www.w3.org/2000/svg"><path d="M256 32C114.6 32 0 125.1 0 240c0 49.6 21.4 95 57 130.7C44.5 421.1 2.7 466 2.2 466.5c-2.2 2.3-2.8 5.7-1.5 8.7S4.8 480 8 480c66.3 0 116-31.8 140.6-51.4 32.7 12.3 69 19.4 107.4 19.4 141.4 0 256-93.1 256-208S397.4 32 256 32zM128 272c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 0c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 0c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"></path></svg>
<div className={'inline-block'}>25 </div>
</a>
</div>
</div>
</div>
))}
</>
:
<>
<span className={'text-sm italic'}>No users review to display</span>
</>
}
</div>
</div>
{screen.width >= 1024 &&
<div className={'bg-secondary'} style={{ display: 'table-cell', position: 'relative', verticalAlign: 'top', width: 330, textAlign: 'left', boxSizing: 'border-box' }}>
<div style={{ padding: '20px 10px' }}>
<div className={'bg-primary p-2'}>
<SeparatorWithAnchor
pageName={"Score Distribution"}
pageLink="#"
titleStyles={{ fontSize: 12, letterSpacing: .9 }}
/>
{Object.entries(userStats.scores_distribution[0]).map(x => (
<div style={{ height: 15, margin: '5px 0'}}>
<div style={{ float: 'left', marginRight: 5, width: 40, fontSize: 11, textAlign: 'center' }}>{scoreDistributionLabel(x[0])}</div>
<div style={{ borderTopRightRadius: 3, borderBottomRightRadius: 3, height: 13, float: 'left', marginRight: 5, minWidth: 1, backgroundColor: '#0be881', width: `calc(${x[1]/userStats.user_stats.score_count * 100}%/2)` }}></div>
<div style={{ float: 'left', fontSize: 11 }}>{x[1]}</div>
<br style={{ clear: 'both' }} />
</div>
))
}
</div>
</div>
</div>
}
</div>
<div style={{ clear: 'both' }} />
</section>
</div>
)
}
export default UserProfile;

View File

@ -0,0 +1,13 @@
.profile-nav {
white-space: nowrap;
overflow: hidden;
text-align: center;
}
.profile-nav div {
display: inline-block;
padding: 0 2%;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 0.75rem;
}

View File

@ -0,0 +1,286 @@
import { useSelector } from "react-redux";
import { DefaultButton, TitleSeparator, WarningButton } from "../../components";
import { UserRootState } from "../../store/type";
import { DEFAULT_AVATAR_IMG } from "../../constants/default";
import { ChangeEvent, TargetedEvent, useRef } from "preact/compat";
import { useState } from "react";
import { enumKeys, useAutosizeTextArea } from "../../utils";
import { SocialMediaEnum } from "../../types/common";
import { SocialMedia, UserInfo } from "../../../src/domains/User";
import './styles.css'
import { deleteUserAvatarService, patchUserAvatarService, patchUserInfoService } from "../../../src/services/users";
import { AxiosError } from "axios";
import { useDispatch } from "react-redux";
import { authAdded } from "../../features/auth/authSlice/authSlice";
interface PageState {
avatar_file: File | null,
avatar: string,
upload_file_err_msg: string,
file_input_key: string
}
function UserSettings() {
const user = useSelector((state: UserRootState) => state.auth)
const dispatch = useDispatch()
const [pageState, setPageState] = useState<PageState>({
avatar_file: null,
avatar: user.avatar_picture,
upload_file_err_msg: '',
file_input_key: Math.random().toString(24)
})
const [isLoading, setIsLoading] = useState(false)
const [form, setForm] = useState<UserInfo>({
about: user.about,
website: user.website,
social_media: user.social_media
})
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useAutosizeTextArea(textAreaRef.current, form.about);
function onChangeFormInput(e: ChangeEvent) {
let event = e.target as HTMLInputElement;
setForm({ ...form, [event.name]: event.value })
}
function handleTextAreaChange(e: ChangeEvent<HTMLTextAreaElement>): void {
const val = e.target as HTMLTextAreaElement;
setForm({ ...form, about: val.value })
}
function onAddSocialMediaClicked() {
let tempArr: Array<SocialMedia> = form.social_media
tempArr.push({ type: SocialMediaEnum.Facebook, value: '' })
setForm({ ...form, social_media: tempArr })
}
function onChangeSocialMediaInput(e: ChangeEvent, idx: number) {
let event = e.target as HTMLInputElement;
let socmed = form.social_media;
socmed[idx].value = event.value;
setForm({ ...form, social_media: socmed })
}
function onChangeSocialMediaType(e: ChangeEvent, idx: number) {
let event = e.target as HTMLOptionElement
let socmed = form.social_media;
socmed[idx].type = event.value;
setForm({ ...form, social_media: socmed })
}
function onDeleteSelectedSocial(idx: number) {
setForm({ ...form, social_media: form.social_media.filter((_, index) => index !== idx) })
}
async function onUploadAvatar(e: TargetedEvent) {
setIsLoading(true)
e.preventDefault();
try {
const form = new FormData();
form.append('file', pageState.avatar_file!)
const res = await patchUserAvatarService(form)
let userStore = {
...user,
avatar_picture: res.data.image_url
}
dispatch(authAdded(userStore));
setIsLoading(false)
} catch(err) {
let error = err as AxiosError;
console.log(error)
setIsLoading(false)
}
setPageState({ ...pageState, file_input_key: Math.random().toString(24)})
}
async function onRemoveAvatar(e: TargetedEvent) {
setIsLoading(true)
e.preventDefault();
try {
await deleteUserAvatarService()
let userStore = {
...user,
avatar_picture: ''
}
dispatch(authAdded(userStore))
setPageState({ ...pageState, avatar: ''})
setIsLoading(false)
} catch(err) {
setIsLoading(false)
const error = err as AxiosError
console.log(error)
}
}
async function onUpdateUserInfo(e: TargetedEvent) {
setIsLoading(true)
e.preventDefault();
try {
console.log(form)
const res = await patchUserInfoService(form)
let userData = {
...user,
about: res.data.about,
website: res.data.website,
social_media: res.data.social_media
}
dispatch(authAdded(userData))
setIsLoading(false)
}catch(err) {
setIsLoading(false)
let error = err as AxiosError;
alert(error)
}
}
function onChangeUploadImage(e: ChangeEvent) {
let event = e.target as HTMLInputElement;
const file = event.files![0] as File;
// if cancel button pressed
if (!file) {
return
}
if (file.type !== "image/png" && file.type !== "image/jpg" && file.type !== "image/jpeg" ) {
setPageState({ ...pageState, upload_file_err_msg: 'ONLY ACCEPT JPEG/PNG/JPG FILE!!!!' })
return
}
if (file.size > 10000000) {
setPageState({ ...pageState, upload_file_err_msg: 'file size too large, max file size 10mb' })
return
}
setPageState({ ...pageState, upload_file_err_msg: '', avatar_file: file, avatar: URL.createObjectURL(file)})
}
return (
<div className={'main-content content'}>
<div className={'mb-4'}>
<h1 className={'text-2xl'}>User settings</h1>
<TitleSeparator titleClasses="mt-8 " pageName={'avatar'} titleStyles={{ letterSpacing: .9 }} />
<div style={{ padding: '0 15px 20px 0', float: 'left', maxWidth: '50%', width: 200 }}>
<img
src={pageState.avatar !== '' ? pageState.avatar : DEFAULT_AVATAR_IMG}
style={{ aspectRatio: '1/1', objectFit: 'cover'}}
/>
</div>
<div className={'text-sm mb-5'} style={{ overflow: 'hidden' }}>
<span className={'mr-3'}>Change avatar</span>
<input name="uploadImage" key={pageState.file_input_key} type={"file"} accept={'image/*'} onChange={onChangeUploadImage} />
{pageState.upload_file_err_msg &&
<p className={'text-xs text-error mt-2'}>{pageState.upload_file_err_msg}</p>
}
<div className={'mt-2 text-tertiary'}>
<p>File extension must be .jpeg, .png, .jpg</p>
<p>File size max 10mb</p>
</div>
</div>
<div className={'text-xs button-avatar-container'}>
<DefaultButton
label="update"
className={isLoading ? 'pointer-events-none' : ''}
style={{ paddingLeft: 36, paddingRight: 36, textTransform: 'uppercase'}}
isLoading={isLoading}
onClick={onUploadAvatar}
/>
<WarningButton
label="remove avatar"
containerClassName="mt-5"
style={{ textTransform: 'uppercase', paddingLeft: 8, paddingRight: 8 }}
className={isLoading ? 'pointer-events-none' : ''}
onClick={onRemoveAvatar}
/>
</div>
<div style={{ clear: 'both' }} />
</div>
<section name="User Info">
<TitleSeparator pageName="INFO" />
<div className={'mb-3'} style={{ maxWidth: 600, minWidth: 280 }}>
<h2 className={'text-sm mb-1 font-bold'}>ABOUT YOU</h2>
<textarea
onChange={handleTextAreaChange}
maxLength={500}
ref={textAreaRef}
className={'p-2 text-sm bg-secondary'}
value={form.about}
style={{ border: 'none', overflow: 'auto', outline: 'none', boxShadow: 'none', width: '100%', minHeight: 100, overflowY: 'hidden' }}
/>
<div className={'mt-2 mb-1'}>
<h2 className={'font-bold text-sm mb-1'}>WEBSITE</h2>
<input className={'bg-secondary text-sm'} placeholder={'www.mywebsite.com'} style={{ width: '100%', borderRadius: 7, padding: '5px 10px' }} type={'text'} onChange={onChangeFormInput} name={'website'} value={form.website} />
</div>
<h2>Social</h2>
{form.social_media.map((val, index) => (
<div className={'mt-2'}>
<select
onChange={(e) => onChangeSocialMediaType(e, index)}
className={'bg-secondary inline-block text-sm'}
name="social_media_type"
id="social_media_type"
style={{ borderRadius: 7, padding: '5px 10px' }}
value={form.social_media[index].type.toLowerCase()}
>
{enumKeys(SocialMediaEnum).map(x => (
<option value={SocialMediaEnum[x]}>{SocialMediaEnum[x]}</option>
))
}
</select>
<input
className={'bg-secondary text-sm ml-1 mr-1 inline-block'}
style={{ width: '66%', borderRadius: 7, padding: '5px 10px' }}
type={'text'}
onChange={(e) => onChangeSocialMediaInput(e, index)}
name={'social_media'}
value={val.value} />
<span className={'text-xs text-gray mr-3'}>( username )</span>
<span
className={'text-sm bg-error'}
style={{ padding: '0 5px' }}
>
<a onClick={() => onDeleteSelectedSocial(index)} style={{ verticalAlign: 'text-bottom' }}>x</a>
</span>
</div>
))
}
<a onClick={onAddSocialMediaClicked}>
<div className={'text-center mt-2 bg-secondary'} style={{ padding: '5px 10px', borderRadius: 7 }}>
+ add social media
</div>
</a>
<div className={'flex flex-row justify-between mt-7'}>
<DefaultButton
label="update"
containerClassName="text-xs"
onClick={onUpdateUserInfo}
style={{ paddingLeft: 36, paddingRight: 36, textTransform: 'uppercase', }}
className={isLoading ? 'pointer-events-none' : ''}
isLoading={isLoading}
/>
<WarningButton
label="delete account"
containerClassName="text-xs"
style={{ textTransform: 'uppercase', paddingLeft: 8, paddingRight: 8 }}
isLoading={isLoading}
className={isLoading ? 'pointer-events-none' : ''}
/>
</div>
</div>
</section>
</div>
)
}
export default UserSettings;

View File

@ -0,0 +1,3 @@
.button-avatar-container p a:hover {
color: white;
}

View File

@ -2,20 +2,30 @@ import Home from "./Home";
import BestLocation from "./BestLocations";
import Discovery from "./Discovery";
import Story from "./Stories";
import NewsEvent from "./NewsEvents";
import NewsEvent from "./NewsEvent";
import LocationDetail from "./LocationDetail";
import Login from './Login';
import NotFound from "./NotFound";
import AddLocation from "./AddLocation";
import Submissions from "./Submissions";
import UserProfile from "./UserProfile";
import UserFeed from "./UserFeed";
import UserSettings from "./UserSettings";
export {
Login,
Home,
UserProfile,
UserFeed,
UserSettings,
NotFound,
BestLocation,
AddLocation,
LocationDetail,
Submissions,
Discovery,
Story,

View File

@ -0,0 +1,23 @@
import { Navigate } from "react-router-dom"
import { useSelector } from "react-redux"
import { UserRootState } from "src/store/type"
export const AdminProtectedRoute = ({children}: any) => {
const user = useSelector((state: UserRootState) => state.auth)
if(!user.is_admin) {
return <Navigate to={"/"} replace />;
}
return children;
}
export const UserProtectedRoute = ({ children }: any) => {
const user = useSelector((state: UserRootState) => state.auth)
if(Object.keys(user).length === 0) {
return <Navigate to={"/"} replace />
}
return children
}

View File

@ -5,40 +5,94 @@ import {
LocationDetail,
NewsEvent,
Story,
Login
AddLocation,
UserProfile,
UserFeed,
UserSettings,
Submissions
} from '../pages';
const routes = [
{
path: "/",
name: "Home",
element: <Home />
},
{
path: "/best-places",
name: "Home",
element: <BestLocation />
},
{
path: "/discover",
name: "Home",
element: <Discovery />
},
{
path: "/stories",
name: "Home",
element: <Story />
},
{
path: "/news-events",
name: "Home",
element: <NewsEvent />
},
{
path: "/location/:id",
name: "LocationDetail",
element: <LocationDetail />
}
]
interface BaseRoutes {
path: string,
name: string,
protectedRoute?: string,
element: any
}
export default routes;
export interface IRoutes {
routes: Array<BaseRoutes>
}
export const getRoutes = (): IRoutes => {
let groupRoutes: Array<BaseRoutes> = [
{
path: "/",
name: "Home",
element: <Home />
},
{
path: "/best-places",
name: "Home",
element: <BestLocation />
},
{
path: "/discover",
name: "Home",
element: <Discovery />
},
{
path: "/stories",
name: "Home",
element: <Story />
},
{
path: "/news-events",
name: "Home",
element: <NewsEvent />
},
{
path: "/location/:id",
name: "LocationDetail",
element: <LocationDetail />
},
// PROTECTED USER ROUTES
{
path: "/add-location",
name: "AddLocation",
protectedRoute: "user",
element: AddLocation
},
{
path: "/user/profile",
name: "UserProfile",
protectedRoute: "user",
element: UserProfile
},
{
path: "/user/settings",
name: "UserSettings",
protectedRoute: "user",
element: UserSettings
},
{
path: "/user/feed",
name: "UserFeed",
protectedRoute: "user",
element: UserFeed
},
//PROTECTED ADMIN ROUTES
{
path: "/admin/submissions",
name: "Submissions",
protectedRoute: "admin",
element: Submissions
},
]
let routes: IRoutes = {
routes: groupRoutes
}
return routes
}

View File

@ -1,8 +1,9 @@
import { AxiosError } from "axios";
import { LOGIN_URI, SIGNUP_URI } from "../constants/api";
import { client } from "./config";
import { IHttpResponse } from "../types/common";
const initialState: IEmptyResponseState = {
const initialState: IHttpResponse = {
data: null,
error: AxiosError
}
@ -15,7 +16,7 @@ interface IAuthentication {
async function createAccountService({ username, password }: IAuthentication) {
const newState = { ...initialState };
try {
const response = await client({ method: 'POST', url: SIGNUP_URI, data: { username, password } })
const response = await client({ method: 'POST', url: SIGNUP_URI, data: { username, password }, withCredentials: true })
newState.data = response.data
newState.error = null
return newState

View File

@ -1,3 +1,4 @@
import { GetRequestPagination } from "../types/common"
import { GET_IMAGES_BY_LOCATION_URI } from "../constants/api"
import { client } from "./config"
import statusCode from "./status-code"

View File

@ -7,13 +7,22 @@ import {
} from "./locations";
import { getImagesByLocationService } from "./images"
import { createAccountService, loginService, logoutService } from "./auth";
import { postReviewLocation } from "./review";
import { postReviewLocation, getCurrentUserLocationReviewService } from "./review";
import { getRegionsService, getProvincesService, getRegenciesService} from "./regions";
import { getUserStatsService } from "./users";
import { getNewsServices, postNewsService} from "./news";
export {
createAccountService,
loginService,
logoutService,
getRegionsService,
getProvincesService,
getRegenciesService,
getUserStatsService,
getListLocationsService,
getListRecentLocationsRatingsService,
getListTopLocationsService,
@ -21,5 +30,9 @@ export {
getLocationTagsService,
getImagesByLocationService,
postReviewLocation
postReviewLocation,
getCurrentUserLocationReviewService,
getNewsServices,
postNewsService
}

View File

@ -1,18 +1,27 @@
import { GET_LIST_LOCATIONS_URI, GET_LIST_RECENT_LOCATIONS_RATING_URI, GET_LIST_TOP_LOCATIONS, GET_LOCATION_TAGS_URI, GET_LOCATION_URI } from "../constants/api";
import { GetRequestPagination, IHttpResponse } from "../types/common";
import {
GET_LIST_LOCATIONS_URI,
GET_LIST_RECENT_LOCATIONS_RATING_URI,
GET_LIST_TOP_LOCATIONS,
GET_LOCATION_TAGS_URI,
GET_LOCATION_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 {
interface GetListLocationsArg extends GetRequestPagination {
order_by?: number,
region_type?: number
}
async function getListLocationsService({ page, page_size }: getListLocationsArg) {
async function getListLocationsService({ page, page_size }: GetListLocationsArg) {
const newState = { ...initialState };
const url = `${GET_LIST_LOCATIONS_URI}?page=${page}&page_size=${page_size}`
try {
@ -48,7 +57,7 @@ async function getListRecentLocationsRatingsService(page_size: Number) {
}
}
async function getListTopLocationsService({ page, page_size, order_by, region_type }: getListLocationsArg) {
async function getListTopLocationsService({ page, page_size, order_by, region_type }: GetListLocationsArg) {
const newState = { ...initialState };
const url = `${GET_LIST_TOP_LOCATIONS}?page=${page}&page_size=${page_size}&order_by=${order_by}&region_type=${region_type}`
try {
@ -102,10 +111,26 @@ async function getLocationTagsService(id: Number) {
}
}
async function createLocationService(data: FormData): Promise<IHttpResponse> {
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;
}
}
export {
getListLocationsService,
getListRecentLocationsRatingsService,
getListTopLocationsService,
getLocationTagsService,
getLocationService
getLocationService,
createLocationService,
}

50
src/services/news.ts Normal file
View File

@ -0,0 +1,50 @@
import { AxiosError } from "axios";
import { GetRequestPagination, IHttpResponse } from "..//types/common";
import { client } from "./config";
import { GET_NEWS_EVENTS_URI, POST_NEWS_EVENTS_URI } from "../../src/constants/api";
interface GetNewsSevice extends GetRequestPagination {
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 {
title: string,
url: string,
description: string,
submitted_by: number
}
async function postNewsService(req: PostNewsServiceBody): Promise<IHttpResponse> {
const newState: IHttpResponse = { data: null, error: null}
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)
}
}
export {
getNewsServices,
postNewsService
}

46
src/services/regions.ts Normal file
View File

@ -0,0 +1,46 @@
import { client } from "./config";
import { GET_PROVINCES, GET_REGENCIES, GET_REGIONS } from "../constants/api";
import { IHttpResponse } from "src/types/common";
async function getRegionsService(): Promise<IHttpResponse> {
const newState: IHttpResponse = {data: null, error: null}
try {
const response = await client({ method: 'GET', url: GET_REGIONS})
newState.data = response.data;
return newState
} catch(err) {
newState.error = err
throw (newState)
}
}
async function getProvincesService(): Promise<IHttpResponse> {
const newState: IHttpResponse = { data: null, error: null}
try {
const response = await client({ method: 'GET', url: GET_PROVINCES})
newState.data = response.data;
return newState
} catch(err) {
newState.error = err
throw (newState)
}
}
async function getRegenciesService(): Promise<IHttpResponse> {
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
} catch(err) {
newState.error = err
throw (newState)
}
}
export {
getRegionsService,
getProvincesService,
getRegenciesService,
}

View File

@ -1,10 +1,11 @@
import { AxiosError } from "axios"
import { client } from "./config";
import { POST_REVIEW_LOCATION_URI } from "../constants/api";
import { GET_CURRENT_USER_REVIEW_LOCATION_URI, POST_REVIEW_LOCATION_URI } from "../constants/api";
const initialState: IEmptyResponseState = {
const initialState: IHttpResponse = {
data: null,
error: AxiosError
error: AxiosError,
status: 0,
}
interface postReviewLocationReq {
@ -29,6 +30,22 @@ async function postReviewLocation(req: postReviewLocationReq) {
}
}
async function getCurrentUserLocationReviewService(location_id: number): Promise<IHttpResponse> {
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)
}
}
export {
postReviewLocation
postReviewLocation,
getCurrentUserLocationReviewService,
}

73
src/services/users.ts Normal file
View File

@ -0,0 +1,73 @@
import { AxiosError } from "axios";
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";
async function getUserStatsService(): Promise<IHttpResponse> {
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)
}
}
async function patchUserAvatarService(form: FormData): Promise<IHttpResponse> {
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);
}
}
async function patchUserInfoService(data: UserInfo): Promise<IHttpResponse> {
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);
}
}
async function deleteUserAvatarService(): Promise<IHttpResponse> {
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);
}
}
export {
getUserStatsService,
patchUserAvatarService,
deleteUserAvatarService,
patchUserInfoService
}

View File

@ -1,6 +1,6 @@
import { IUser } from "../features/auth/authSlice/type";
import { User } from "../domains";
import { RootState } from "./config";
export interface UserRootState extends RootState {
auth: IUser
auth: User
}

View File

@ -1,13 +1,44 @@
type BaseNullValueRes = { Valid: boolean };
type NullValueRes<Key extends string, _> = BaseNullValueRes & Record<Key, string | number>
import { Province, Regency, Region } from "../domains";
interface GetRequestPagination {
type BaseNullValueRes = { Valid: boolean };
export type NullValueRes<Key extends string, _> = BaseNullValueRes & Record<Key, string | number>
export interface GetRequestPagination {
page: number,
page_size: number,
}
interface IEmptyResponseState {
export interface IHttpResponse {
data: any,
error: any,
};
status?: number,
};
export interface IDropdownInputProps {
label: string,
value: string
}
export interface IndonesiaRegionsInfo {
regions?: Array<Region>,
provinces?: Array<Province>,
regencies?: Array<Regency>,
}
export enum LocationType {
Beach = "beach",
AmusementPark = "amusement park",
Culinary = "culinary",
HikingCamping = "hiking / camping",
Other = "other"
}
// https://www.similarweb.com/top-websites/indonesia/computers-electronics-and-technology/social-networks-and-online-communities/#:~:text=facebook.com%20ranked%20number%201,Media%20Networks%20websites%20in%20Indonesia.
// maybe should add ppgames.net too BASED
export enum SocialMediaEnum {
Facebook = "facebook",
Instagram = "instagram",
Tiktox = "tiktok",
X = "x",
Youtube = "youtube"
}

View File

@ -2,4 +2,13 @@ import { AxiosError } from "axios";
export function handleAxiosError(error: AxiosError) {
return error.response?.data
}
export function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
return Object.keys(obj).filter(k => Number.isNaN(+k)) as K[];
}
export function isUrl(val: string): boolean {
var urlPattern = /^https:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
return urlPattern.test(val);
}

View File

@ -1,7 +1,9 @@
import useAutosizeTextArea from "./useAutosizeTextArea";
import { handleAxiosError } from "./common";
import { handleAxiosError, enumKeys, isUrl } from "./common";
export {
useAutosizeTextArea,
handleAxiosError
handleAxiosError,
enumKeys,
isUrl
}

View File

@ -9,6 +9,8 @@ export default {
secondary: '#2f3136',
tertiary: '#a8adb3',
error: '#ff5454',
gray: '#797979',
yellow: '#e5c453'
},
borderColor: {
primary: '#38444d',

View File

@ -204,6 +204,13 @@
dependencies:
regenerator-runtime "^0.14.0"
"@babel/runtime@^7.20.13":
version "7.23.1"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.1.tgz#72741dc4d413338a91dcb044a86f3c0bc402646d"
integrity sha512-hC2v6p8ZSI/W0HUzh3V8C5g+NwSKzKPtJwSpTjwl0o297GP9+ZLQSkdvHz46CM3LqyoXxq+5G9komY+eSqSO0g==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/template@^7.22.5":
version "7.22.5"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec"
@ -1220,6 +1227,15 @@ react-router@6.16.0:
dependencies:
"@remix-run/router" "1.9.0"
react-textarea-autosize@^8.5.3:
version "8.5.3"
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz#d1e9fe760178413891484847d3378706052dd409"
integrity sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ==
dependencies:
"@babel/runtime" "^7.20.13"
use-composed-ref "^1.3.0"
use-latest "^1.2.1"
react@^18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
@ -1403,6 +1419,23 @@ update-browserslist-db@^1.0.11:
escalade "^3.1.1"
picocolors "^1.0.0"
use-composed-ref@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda"
integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==
use-isomorphic-layout-effect@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
use-latest@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.1.tgz#d13dfb4b08c28e3e33991546a2cee53e14038cf2"
integrity sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==
dependencies:
use-isomorphic-layout-effect "^1.1.1"
use-sync-external-store@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"