update,
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import { IonContent, IonFab, IonFabButton, IonHeader, IonIcon, IonModal, IonPage, IonSearchbar, IonToolbar, isPlatform, useIonViewWillEnter } from '@ionic/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getRecords } from '../main/yelp';
|
||||
|
||||
import styles from "../styles/Map.module.scss";
|
||||
|
||||
import { Map, Marker, Overlay } from "pigeon-maps";
|
||||
import { maptiler } from 'pigeon-maps/providers';
|
||||
|
||||
import { MapOverlay } from "../components/MapOverlay";
|
||||
import { CurrentPointOverlay } from "../components/CurrentPointOverlay";
|
||||
import { flashOffOutline, flashOutline, list } from 'ionicons/icons';
|
||||
|
||||
import RecordsStore from '../store/RecordsStore';
|
||||
import { fetchRecords } from '../store/Selectors';
|
||||
import { getLocation } from '../main/utils';
|
||||
import { ListModal } from '../components/ListModal';
|
||||
|
||||
const maptilerProvider = maptiler('d5JQJPLLuap8TkJJlTdJ', 'streets');
|
||||
|
||||
const Tab1 = () => {
|
||||
|
||||
const web = isPlatform("web" || "pwa" || "mobileweb" || "");
|
||||
|
||||
// UNCOMMENT THESE TO USE CURRENT LOCATION.
|
||||
|
||||
// const [ currentPoint, setCurrentPoint ] = useState(false);
|
||||
|
||||
// useEffect(() => {
|
||||
|
||||
// const getCurrentLocation = async () => {
|
||||
|
||||
// const fetchedLocation = await getLocation();
|
||||
// setCurrentPoint(fetchedLocation.currentLocation);
|
||||
// }
|
||||
|
||||
// getCurrentLocation();
|
||||
// }, []);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
|
||||
getRecords(currentPoint);
|
||||
});
|
||||
|
||||
const [ currentPoint, setCurrentPoint ] = useState({ latitude: 40.8264691, longitude: -73.9549618 });
|
||||
|
||||
const [ showCurrentPointInfo, setShowCurrentPointInfo ] = useState(false);
|
||||
|
||||
const records = RecordsStore.useState(fetchRecords);
|
||||
const center = RecordsStore.useState(s => s.center);
|
||||
|
||||
const [ results, setResults ] = useState(false);
|
||||
const [ zoom, setZoom ] = useState(14);
|
||||
|
||||
const [ searchTerm, setSearchTerm ] = useState("");
|
||||
const [ moveMode, setMoveMode ] = useState(false);
|
||||
|
||||
const [ showListModal, setShowListModal ] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const getData = async () => {
|
||||
|
||||
await getRecords(currentPoint);
|
||||
}
|
||||
|
||||
getData();
|
||||
}, [ currentPoint ]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
setResults([...records]);
|
||||
}, [ records ]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const search = searchTerm.toLowerCase();
|
||||
var searchResults = [];
|
||||
|
||||
if (searchTerm !== "") {
|
||||
|
||||
records.forEach(record => {
|
||||
|
||||
if (record.name.toLowerCase().includes(search)) {
|
||||
|
||||
searchResults.push(record);
|
||||
}
|
||||
});
|
||||
|
||||
setResults(searchResults);
|
||||
} else {
|
||||
|
||||
setResults([...records]);
|
||||
}
|
||||
|
||||
}, [ searchTerm ]);
|
||||
|
||||
const showMarkerInfo = (e, index) => {
|
||||
|
||||
const tempRecords = JSON.parse(JSON.stringify(results));
|
||||
|
||||
// Hide all current marker infos
|
||||
setShowCurrentPointInfo(false);
|
||||
!tempRecords[index].showInfo && tempRecords.forEach(tempRecord => tempRecord.showInfo = false);
|
||||
tempRecords[index].showInfo = !tempRecords[index].showInfo;
|
||||
|
||||
setResults(tempRecords);
|
||||
}
|
||||
|
||||
const hideMarkers = () => {
|
||||
|
||||
const tempRecords = JSON.parse(JSON.stringify(results));
|
||||
tempRecords.forEach(tempRecord => tempRecord.showInfo = false);
|
||||
setResults(tempRecords);
|
||||
setShowCurrentPointInfo(false);
|
||||
}
|
||||
|
||||
const handleMapClick = e => {
|
||||
|
||||
const clickedPoint = e.latLng;
|
||||
setCurrentPoint({ latitude: clickedPoint[0], longitude: clickedPoint[1] });
|
||||
setMoveMode(false);
|
||||
}
|
||||
|
||||
const handleShowCurrentPointInfo = () => {
|
||||
|
||||
hideMarkers();
|
||||
setShowCurrentPointInfo(!showCurrentPointInfo);
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent fullscreen>
|
||||
{ (center && center.latitude && center.longitude) && results &&
|
||||
<>
|
||||
|
||||
<div className={ styles.overlaySearch } style={{ marginTop: web ? "0.5rem" : "3.5rem" }}>
|
||||
<IonSearchbar placeholder="Search plotted points" animated={ true } value={ searchTerm } onIonChange={ e => setSearchTerm(e.target.value) } />
|
||||
</div>
|
||||
|
||||
<Map onClick={ e => moveMode ? handleMapClick(e) : hideMarkers(e) } defaultCenter={ [center.latitude, center.longitude] } defaultZoom={ zoom } provider={ maptilerProvider } touchEvents={ true }>
|
||||
|
||||
<Marker onClick={ handleShowCurrentPointInfo } color="red" width={ 50 } anchor={ currentPoint ? [ currentPoint.latitude, currentPoint.longitude] : [ center.latitude, center.longitude] } />
|
||||
|
||||
{ results.map((record, index) => {
|
||||
|
||||
return <Marker onClick={ e => showMarkerInfo(e, index) } key={ index } color="#3578e5" width={ 50 } anchor={ [ record.latitude, record.longitude ] } />
|
||||
})}
|
||||
|
||||
{ results.map((record, index) => {
|
||||
|
||||
if (record.showInfo) {
|
||||
|
||||
return (
|
||||
<Overlay key={ index } anchor={ [ record.latitude, record.longitude ] } offset={[95, 304]}>
|
||||
<MapOverlay record={ record } />
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
{ showCurrentPointInfo &&
|
||||
|
||||
<Overlay anchor={ [ currentPoint.latitude, currentPoint.longitude ] } offset={[95, 153]}>
|
||||
<CurrentPointOverlay />
|
||||
</Overlay>
|
||||
}
|
||||
</Map>
|
||||
|
||||
<IonFab vertical="bottom" horizontal="end" slot="fixed" onClick={ () => setMoveMode(!moveMode) }>
|
||||
<IonFabButton>
|
||||
<IonIcon icon={ moveMode ? flashOffOutline : flashOutline } />
|
||||
</IonFabButton>
|
||||
</IonFab>
|
||||
|
||||
<IonFab vertical="bottom" horizontal="start" slot="fixed" onClick={ () => setShowListModal(!showListModal) }>
|
||||
<IonFabButton>
|
||||
<IonIcon icon={ list } />
|
||||
</IonFabButton>
|
||||
</IonFab>
|
||||
|
||||
<IonModal isOpen={ showListModal } onDidDismiss={ () => setShowListModal(false) } swipeToClose={ true } initialBreakpoint={ 0.6 } breakpoints={ [0, 0.6, 1] } backdropBreakpoint={ 0.6 }>
|
||||
<ListModal hideModal={ () => setShowListModal(false) } searchTerm={ searchTerm } search={ setSearchTerm } records={ results } />
|
||||
</IonModal>
|
||||
</>
|
||||
}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tab1;
|
@@ -0,0 +1,66 @@
|
||||
import { IonButton, IonCard, IonCardHeader, IonCardSubtitle, IonContent, IonHeader, IonIcon, IonNote, IonPage, IonRow, IonText, IonTitle, IonToolbar } from '@ionic/react';
|
||||
import { arrowForward, navigateOutline } from 'ionicons/icons';
|
||||
import { RatingStar } from '../components/RatingStar';
|
||||
import RecordsStore from '../store/RecordsStore';
|
||||
import { fetchRecords } from '../store/Selectors';
|
||||
|
||||
import styles from "../styles/ViewAll.module.scss";
|
||||
|
||||
const Tab2 = () => {
|
||||
|
||||
const records = RecordsStore.useState(fetchRecords);
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>All places in your location</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent fullscreen>
|
||||
<IonHeader collapse="condense">
|
||||
<IonToolbar>
|
||||
<IonTitle size="large">Feeling hungry?</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
{ records.map((record, index) => {
|
||||
|
||||
const imageURL = record.imageURL ? record.imageURL : "/placeholder.jpeg";
|
||||
const rating = Math.floor(record.rating).toFixed(0);
|
||||
|
||||
return (
|
||||
<IonCard key={ `record_${ index }` } className={ `${ styles.viewCard } animate__animated animate__faster animate__fadeIn` } routerLink={ `/list/${ record.id }` }>
|
||||
<div className={ styles.cardImage } style={{ backgroundImage: `url(${ imageURL })` }} />
|
||||
<IonCardHeader>
|
||||
|
||||
{ Array.apply(null, { length: 5 }).map((e, i) => (
|
||||
|
||||
<RatingStar key={ i } rated={ rating > i } />
|
||||
))}
|
||||
|
||||
<IonCardSubtitle>{ record.name }</IonCardSubtitle>
|
||||
<IonNote color="medium">{ record.displayAddress }</IonNote>
|
||||
|
||||
<IonRow className="ion-justify-content-between ion-align-items-center">
|
||||
<IonText color="primary">
|
||||
<p>
|
||||
<IonIcon icon={ navigateOutline } />
|
||||
{ record.distance } miles away
|
||||
</p>
|
||||
</IonText>
|
||||
|
||||
<IonButton size="small" color="primary">
|
||||
<IonIcon icon={ arrowForward } />
|
||||
</IonButton>
|
||||
</IonRow>
|
||||
</IonCardHeader>
|
||||
</IonCard>
|
||||
);
|
||||
})}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tab2;
|
@@ -0,0 +1,25 @@
|
||||
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react';
|
||||
import ExploreContainer from '../components/ExploreContainer';
|
||||
import './Tab3.css';
|
||||
|
||||
const Tab3 = () => {
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>Tab 3</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent fullscreen>
|
||||
<IonHeader collapse="condense">
|
||||
<IonToolbar>
|
||||
<IonTitle size="large">Tab 3</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<ExploreContainer name="Tab 3 page" />
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tab3;
|
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
IonAvatar,
|
||||
IonBackButton,
|
||||
IonBadge,
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonCardSubtitle,
|
||||
IonCol,
|
||||
IonContent,
|
||||
IonGrid,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonNote,
|
||||
IonPage,
|
||||
IonRow,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
useIonLoading,
|
||||
useIonModal,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { callOutline } from 'ionicons/icons';
|
||||
import { Map, Marker } from 'pigeon-maps';
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { RatingStar } from '../components/RatingStar';
|
||||
import { getRecord } from '../main/yelp';
|
||||
import { RecordsStore } from '../store';
|
||||
import { fetchRecord } from '../store/Selectors';
|
||||
|
||||
import styles from '../styles/ViewPlace.module.scss';
|
||||
|
||||
import { maptiler } from 'pigeon-maps/providers';
|
||||
import { useRef } from 'react';
|
||||
const maptilerProvider = maptiler('d5JQJPLLuap8TkJJlTdJ', 'streets');
|
||||
|
||||
const ViewPlace = ({}) => {
|
||||
const pageRef = useRef();
|
||||
const [present, dismiss] = useIonLoading();
|
||||
const { id } = useParams();
|
||||
const record = RecordsStore.useState(fetchRecord(id));
|
||||
const [extendedRecord, setExtendedRecord] = useState(false);
|
||||
|
||||
const MapView = () => (
|
||||
<IonContent className="ion-text-center">
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonButton onClick={dismissModal}>Close</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle>Map View</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<Map
|
||||
defaultCenter={[extendedRecord.coordinates.latitude, extendedRecord.coordinates.longitude]}
|
||||
defaultZoom={13}
|
||||
provider={maptilerProvider}
|
||||
touchEvents={true}
|
||||
>
|
||||
<Marker
|
||||
color="red"
|
||||
width={50}
|
||||
anchor={[extendedRecord.coordinates.latitude, extendedRecord.coordinates.longitude]}
|
||||
/>
|
||||
</Map>
|
||||
</IonContent>
|
||||
);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
const getData = async () => {
|
||||
const extendedData = await getRecord(id);
|
||||
setExtendedRecord(extendedData);
|
||||
dismiss();
|
||||
};
|
||||
|
||||
present('Fetching extended details...');
|
||||
getData();
|
||||
});
|
||||
|
||||
const imageURL = record.imageURL ? record.imageURL : '/placeholder.jpeg';
|
||||
const rating = Math.floor(record.rating).toFixed(0);
|
||||
|
||||
const [presentModal, dismissModal] = useIonModal(MapView);
|
||||
|
||||
return (
|
||||
<IonPage className={styles.page} ref={pageRef}>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton text="All places" />
|
||||
</IonButtons>
|
||||
<IonTitle>{record.distance} miles away</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent fullscreen>
|
||||
<IonHeader collapse="condense">
|
||||
<IonToolbar>
|
||||
<div className={styles.cardImage} style={{ backgroundImage: `url(${imageURL})` }}>
|
||||
<IonRow>
|
||||
<IonCol size="8">
|
||||
<IonCardSubtitle>
|
||||
{record.name}
|
||||
|
||||
<div>
|
||||
{Array.apply(null, { length: 5 }).map((e, i) => (
|
||||
<RatingStar key={i} rated={rating > i} small={true} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<IonNote>{record.distance} miles away</IonNote>
|
||||
</IonCardSubtitle>
|
||||
</IonCol>
|
||||
</IonRow>
|
||||
</div>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonGrid>
|
||||
<IonRow className={styles.categoryContainer}>
|
||||
<IonCol size="8">
|
||||
{extendedRecord.categories &&
|
||||
extendedRecord.categories.length > 0 &&
|
||||
extendedRecord.categories.map((category, index) => {
|
||||
return (
|
||||
<IonCol key={index} size="1">
|
||||
<IonBadge key={`category_${index}`} color="primary">
|
||||
{category.title}
|
||||
</IonBadge>
|
||||
</IonCol>
|
||||
);
|
||||
})}
|
||||
</IonCol>
|
||||
|
||||
<IonCol size="4" className="ion-justify-content-between">
|
||||
<a href={record.url} target="_blank" rel="noreferrer">
|
||||
View on Yelp →
|
||||
</a>
|
||||
</IonCol>
|
||||
</IonRow>
|
||||
|
||||
<IonRow className="ion-margin-top">
|
||||
<IonCol size="6">
|
||||
<a href={`tel:${record.phone}`}>
|
||||
<IonButton color="primary" expand="block">
|
||||
<IonIcon icon={callOutline} />
|
||||
</IonButton>
|
||||
</a>
|
||||
</IonCol>
|
||||
|
||||
<IonCol size="6">
|
||||
<IonButton
|
||||
color="primary"
|
||||
expand="block"
|
||||
fill="outline"
|
||||
onClick={() =>
|
||||
presentModal({
|
||||
swipetoClose: true,
|
||||
presentingElement: pageRef.current,
|
||||
})
|
||||
}
|
||||
>
|
||||
View on map
|
||||
</IonButton>
|
||||
</IonCol>
|
||||
</IonRow>
|
||||
|
||||
{extendedRecord.photos && extendedRecord.photos.length > 0 && (
|
||||
<IonRow className="ion-margin-top">
|
||||
<IonCol size="12">
|
||||
<IonCardSubtitle>Photos ({extendedRecord.photos.length})</IonCardSubtitle>
|
||||
</IonCol>
|
||||
</IonRow>
|
||||
)}
|
||||
|
||||
<IonRow>
|
||||
{extendedRecord.photos &&
|
||||
extendedRecord.photos.length > 0 &&
|
||||
extendedRecord.photos.map((photo, index) => {
|
||||
if (index < 3) {
|
||||
return (
|
||||
<IonCol key={index} size="4">
|
||||
<div
|
||||
className={styles.cardImage}
|
||||
style={{ backgroundImage: `url(${photo})` }}
|
||||
/>
|
||||
</IonCol>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</IonRow>
|
||||
|
||||
{extendedRecord.reviews && extendedRecord.reviews.length > 0 && (
|
||||
<IonRow className="ion-margin-top">
|
||||
<IonCol size="12">
|
||||
<IonCardSubtitle>Reviews ({extendedRecord.reviews.length})</IonCardSubtitle>
|
||||
</IonCol>
|
||||
</IonRow>
|
||||
)}
|
||||
|
||||
<IonRow>
|
||||
{extendedRecord.reviews &&
|
||||
extendedRecord.reviews.length > 0 &&
|
||||
extendedRecord.reviews.map((review, index) => {
|
||||
return (
|
||||
<IonCol key={`review_${index}`} size="12">
|
||||
<IonItem lines="full">
|
||||
<IonLabel className="ion-text-wrap">
|
||||
<IonRow className="ion-align-items-center ion-justify-content-between">
|
||||
<IonAvatar>
|
||||
<img src={review.user.image_url} />
|
||||
</IonAvatar>
|
||||
<IonCardSubtitle>{review.user.name}</IonCardSubtitle>
|
||||
|
||||
<IonButton color="primary">Full review on Yelp →</IonButton>
|
||||
</IonRow>
|
||||
<p className="ion-padding-top">{review.text}</p>
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
</IonCol>
|
||||
);
|
||||
})}
|
||||
</IonRow>
|
||||
</IonGrid>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewPlace;
|
5
03_source/mobile/src/pages/DemoRestaurantFinder/NOTES.md
Normal file
5
03_source/mobile/src/pages/DemoRestaurantFinder/NOTES.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# notes
|
||||
|
||||
## TODO
|
||||
|
||||
need server for map showing
|
@@ -0,0 +1,11 @@
|
||||
import { IonCardSubtitle, IonNote } from "@ionic/react";
|
||||
import styles from "../styles/MapOverlay.module.scss";
|
||||
|
||||
export const CurrentPointOverlay = () => (
|
||||
|
||||
<div className={ styles.overlayContainer }>
|
||||
|
||||
<IonCardSubtitle>Current Location</IonCardSubtitle>
|
||||
<IonNote color="medium">Click on the lightning button then choose a new point on the map to view places around that point.</IonNote>
|
||||
</div>
|
||||
)
|
@@ -0,0 +1,24 @@
|
||||
.container {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.container strong {
|
||||
font-size: 20px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.container p {
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.container a {
|
||||
text-decoration: none;
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
import './ExploreContainer.css';
|
||||
|
||||
const ExploreContainer = ({ name }) => {
|
||||
return (
|
||||
<div className="container">
|
||||
<strong>{name}</strong>
|
||||
<p>Explore <a target="_blank" rel="noopener noreferrer" href="https://ionicframework.com/docs/components">UI Components</a></p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExploreContainer;
|
@@ -0,0 +1,57 @@
|
||||
import { IonButton, IonCard, IonCardHeader, IonCardSubtitle, IonCol, IonGrid, IonIcon, IonNote, IonRow, IonSearchbar, IonText } from "@ionic/react";
|
||||
import { RatingStar } from "./RatingStar";
|
||||
|
||||
import styles from "../styles/ViewAll.module.scss";
|
||||
import { arrowForward, navigateOutline } from "ionicons/icons";
|
||||
|
||||
export const ListModal = ({ records, searchTerm, search, hideModal }) => {
|
||||
|
||||
return (
|
||||
|
||||
<IonGrid>
|
||||
|
||||
<IonRow>
|
||||
<IonCol size="12">
|
||||
<IonSearchbar placeholder="Search for a place..." value={ searchTerm } onIonChange={ e => search(e.target.value) } />
|
||||
</IonCol>
|
||||
</IonRow>
|
||||
|
||||
<IonRow>
|
||||
<div className={ styles.viewCardContainerModal }>
|
||||
{ records.map((record, index) => {
|
||||
|
||||
const rating = Math.floor(record.rating).toFixed(0);
|
||||
|
||||
return (
|
||||
<IonCard key={ `record_${ index }` } className={ `${ styles.viewCardModal } animate__animated animate__faster animate__fadeIn` } routerLink={ `/list/${ record.id }` } onClick={ hideModal }>
|
||||
<IonCardHeader>
|
||||
|
||||
{ Array.apply(null, { length: 5 }).map((e, i) => (
|
||||
|
||||
<RatingStar key={ i } rated={ rating > i } />
|
||||
))}
|
||||
|
||||
<IonCardSubtitle>{ record.name }</IonCardSubtitle>
|
||||
<IonNote color="medium">{ record.displayAddress }</IonNote>
|
||||
|
||||
<IonRow className="ion-justify-content-between ion-align-items-center">
|
||||
<IonText color="primary">
|
||||
<p>
|
||||
<IonIcon icon={ navigateOutline } />
|
||||
{ record.distance } miles away
|
||||
</p>
|
||||
</IonText>
|
||||
|
||||
<IonButton size="small" color="primary">
|
||||
<IonIcon icon={ arrowForward } />
|
||||
</IonButton>
|
||||
</IonRow>
|
||||
</IonCardHeader>
|
||||
</IonCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</IonRow>
|
||||
</IonGrid>
|
||||
);
|
||||
}
|
@@ -0,0 +1,50 @@
|
||||
import { IonBadge, IonButton, IonCardSubtitle, IonCol, IonIcon, IonNote, IonRow } from "@ionic/react";
|
||||
import { arrowForward, call, callOutline, navigateOutline, phoneLandscapeOutline, phonePortraitOutline, pricetag, pricetags, pricetagsOutline } from "ionicons/icons";
|
||||
import styles from "../styles/MapOverlay.module.scss";
|
||||
|
||||
export const MapOverlay = ({ record }) => (
|
||||
|
||||
<div className={ styles.overlayContainer }>
|
||||
|
||||
<IonCardSubtitle>{ record.name }</IonCardSubtitle>
|
||||
<IonNote color="medium">{ record.displayAddress }</IonNote>
|
||||
<IonBadge color="dark">{ record.rating } star rating</IonBadge>
|
||||
|
||||
<p>
|
||||
<IonIcon icon={ navigateOutline } />
|
||||
{ record.distance } miles away
|
||||
</p>
|
||||
|
||||
{ record.phone &&
|
||||
<p>
|
||||
<IonIcon icon={ call } />
|
||||
{ record.phone }
|
||||
</p>
|
||||
}
|
||||
|
||||
<IonRow className="ion-no-padding ion-no-margin ion-margin-top">
|
||||
<IonCol size="12" className="ion-no-padding ion-no-margin">
|
||||
<IonButton color="primary" fill="solid" size="small" expand="block" routerLink={ `/list/${ record.id }` }>
|
||||
View →
|
||||
</IonButton>
|
||||
</IonCol>
|
||||
</IonRow>
|
||||
|
||||
<IonRow className="ion-no-padding ion-no-margin">
|
||||
|
||||
{ record.phone &&
|
||||
<IonCol size="6" className="ion-no-padding ion-no-margin">
|
||||
<IonButton color="primary" fill="outline" size="small" expand="block">
|
||||
<IonIcon icon={ callOutline } />
|
||||
</IonButton>
|
||||
</IonCol>
|
||||
}
|
||||
|
||||
<IonCol size={ record.phone ? "6" : "12" } className="ion-no-padding ion-no-margin">
|
||||
<IonButton color="primary" fill="outline" size="small" expand="block">
|
||||
<IonIcon icon={ navigateOutline } />
|
||||
</IonButton>
|
||||
</IonCol>
|
||||
</IonRow>
|
||||
</div>
|
||||
)
|
@@ -0,0 +1,8 @@
|
||||
import { IonIcon } from "@ionic/react";
|
||||
import { star, starOutline } from "ionicons/icons";
|
||||
import styles from "../styles/RatingStar.module.scss";
|
||||
|
||||
export const RatingStar = ({ rated = false, small = false }) => (
|
||||
|
||||
<IonIcon className={ rated ? styles.star : styles.outlineStar } icon={ star } style={ small ? { fontSize: "0.6rem" } : {} } />
|
||||
);
|
45
03_source/mobile/src/pages/DemoRestaurantFinder/index.tsx
Normal file
45
03_source/mobile/src/pages/DemoRestaurantFinder/index.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { IonIcon, IonLabel, IonRouterOutlet, IonTabBar, IonTabButton, IonTabs } from '@ionic/react';
|
||||
|
||||
import { cloudOutline, listOutline, mapOutline, searchOutline } from 'ionicons/icons';
|
||||
import { Route, Redirect } from 'react-router';
|
||||
|
||||
import Tab1 from './AppPages/Tab1';
|
||||
import Tab2 from './AppPages/Tab2';
|
||||
import ViewPlace from './AppPages/ViewPlace';
|
||||
|
||||
import './style.scss';
|
||||
|
||||
function DemoRestaurantFinder() {
|
||||
return (
|
||||
<IonTabs>
|
||||
<IonRouterOutlet>
|
||||
<Route exact path="/demo-restaurant-finder/map">
|
||||
<Tab1 />
|
||||
</Route>
|
||||
<Route exact path="/demo-restaurant-finder/list">
|
||||
<Tab2 />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/demo-restaurant-finder/list/:id">
|
||||
<ViewPlace />
|
||||
</Route>
|
||||
|
||||
<Redirect exact path="/demo-restaurant-finder" to="/demo-restaurant-finder/map" />
|
||||
</IonRouterOutlet>
|
||||
|
||||
{/* */}
|
||||
<IonTabBar slot="bottom">
|
||||
<IonTabBar slot="bottom">
|
||||
<IonTabButton tab="tab1" href="/map">
|
||||
<IonIcon icon={mapOutline} />
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="tab2" href="/list">
|
||||
<IonIcon icon={listOutline} />
|
||||
</IonTabButton>
|
||||
</IonTabBar>
|
||||
</IonTabBar>
|
||||
</IonTabs>
|
||||
);
|
||||
}
|
||||
|
||||
export default DemoRestaurantFinder;
|
@@ -0,0 +1,40 @@
|
||||
import { Geolocation } from '@capacitor/geolocation';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
const platform = Capacitor.getPlatform();
|
||||
|
||||
export const getLocation = async () => {
|
||||
|
||||
const permission = await Geolocation.checkPermissions();
|
||||
var coordinates;
|
||||
|
||||
if (permission.location === "granted") {
|
||||
|
||||
var options = {
|
||||
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: Infinity
|
||||
};
|
||||
|
||||
coordinates = await Geolocation.getCurrentPosition(options);
|
||||
} else {
|
||||
|
||||
if (platform === "web") {
|
||||
|
||||
console.log("Permission Denied.");
|
||||
} else {
|
||||
|
||||
await Geolocation.requestPermissions();
|
||||
coordinates = await Geolocation.getCurrentPosition(options);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
currentLocation: {
|
||||
|
||||
latitude: coordinates.coords.latitude,
|
||||
longitude: coordinates.coords.longitude
|
||||
}
|
||||
}
|
||||
}
|
32
03_source/mobile/src/pages/DemoRestaurantFinder/main/yelp.js
Normal file
32
03_source/mobile/src/pages/DemoRestaurantFinder/main/yelp.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { setStore } from "../store/RecordsStore";
|
||||
|
||||
export const getRecords = async (currentPoint) => {
|
||||
|
||||
|
||||
// Replace lat/long with values from get current location.
|
||||
// Allow choosing of radius?
|
||||
// Offset could = amount loaded in an infinite scroll?
|
||||
var latitude = currentPoint.latitude, longitude = currentPoint.longitude, radius = 1000, offset = 0;
|
||||
const response = await fetch(`http://localhost:4000/get-records?latitude=${ latitude }&longitude=${ longitude }&radius=${ radius }&offset=${ offset }`);
|
||||
const data = await response.json();
|
||||
setStore(data);
|
||||
}
|
||||
|
||||
export const getRecord = async recordId => {
|
||||
|
||||
const response = await fetch(`http://localhost:4000/get-record?id=${ recordId }`);
|
||||
const data = await response.json();
|
||||
|
||||
const response2 = await fetch(`http://localhost:4000/get-reviews?id=${ recordId }`);
|
||||
const data2 = await response2.json();
|
||||
|
||||
data.reviews = data2.reviews;
|
||||
return data;
|
||||
}
|
||||
|
||||
export const getCategories = async () => {
|
||||
|
||||
const response = await fetch(`http://localhost:4000/get-categories`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
152
03_source/mobile/src/pages/DemoRestaurantFinder/server.js
Normal file
152
03_source/mobile/src/pages/DemoRestaurantFinder/server.js
Normal file
@@ -0,0 +1,152 @@
|
||||
const axios = require('axios');
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
var session = require('express-session');
|
||||
var cors = require('cors');
|
||||
|
||||
// Use session
|
||||
app.use(
|
||||
session({
|
||||
secret: 'Ionic Rocks!',
|
||||
cookie: { maxAge: 86400000 },
|
||||
resave: true,
|
||||
saveUninitialized: true,
|
||||
})
|
||||
);
|
||||
app.use(cors({ origin: '*' }));
|
||||
|
||||
app.listen(process.env.PORT || 4000, function () {
|
||||
console.log('server is running...');
|
||||
});
|
||||
|
||||
// DON'T LEAVE THIS API KEY IN YOUR PRODUCTION APPS
|
||||
// This is a test account of mine, so i've left this in for demo purposes.
|
||||
// Secure your nodejs server and API key when building real things!
|
||||
let API_KEY =
|
||||
'd02MG5N6GCJ0Y6GN5OHYCIW7XBHCbuu0O0w6sxtZmHMuhn-tgvOK1NaFIgST-4r8E3CQp6APMNMjKs0sZV3UHtQO-e32ysCBY-3nGqxJGsvjTCZ_eEM5jE14H-XuYHYx';
|
||||
|
||||
// REST API for Yelp
|
||||
let yelpAPI = axios.create({
|
||||
baseURL: 'https://api.yelp.com/v3/',
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
app.get('/get-record', function (req, res) {
|
||||
const { id } = req.query;
|
||||
|
||||
yelpAPI(`/businesses/${id}`).then(({ data }) => {
|
||||
res.send(JSON.stringify(data));
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/get-reviews', function (req, res) {
|
||||
const { id } = req.query;
|
||||
|
||||
yelpAPI(`/businesses/${id}/reviews`).then(({ data }) => {
|
||||
res.send(JSON.stringify(data));
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/get-categories', function (req, res) {
|
||||
yelpAPI('/categories').then(({ data }) => {
|
||||
res.send(JSON.stringify(data));
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/get-records', function (req, res) {
|
||||
const { latitude, longitude, radius } = req.query;
|
||||
const categories = 'restaurant,takeaway';
|
||||
|
||||
const params = {
|
||||
latitude,
|
||||
longitude,
|
||||
radius,
|
||||
categories,
|
||||
};
|
||||
|
||||
yelpAPI('/businesses/search', { params: params }).then(({ data }) => {
|
||||
const allRecords = parseDetails(data);
|
||||
res.send(JSON.stringify({ allRecords, center: data.region.center }));
|
||||
});
|
||||
});
|
||||
|
||||
const parseDetails = (info) => {
|
||||
console.log('Parsing details...');
|
||||
var records = [];
|
||||
|
||||
var parsedInfo = info;
|
||||
var businesses = parsedInfo.businesses;
|
||||
var total = parsedInfo.total;
|
||||
|
||||
var distance = 0;
|
||||
var distanceMiles = 0;
|
||||
|
||||
for (var i = 0; i < businesses.length; i++) {
|
||||
var id = businesses[i].id;
|
||||
var url = businesses[i].url;
|
||||
var imageURL = businesses[i].image_url;
|
||||
var name = businesses[i].name;
|
||||
var alias = businesses[i].alias;
|
||||
var phone = businesses[i].display_phone;
|
||||
var price = businesses[i].price;
|
||||
var rating = businesses[i].rating;
|
||||
|
||||
var isClosed = businesses[i].is_closed;
|
||||
var isOpen = isClosed == true ? false : true;
|
||||
|
||||
var coordinates = businesses[i].coordinates;
|
||||
var latitude = coordinates.latitude;
|
||||
var longitude = coordinates.longitude;
|
||||
|
||||
var displayAddress = '';
|
||||
|
||||
if (businesses[i].location) {
|
||||
var addressDetails = businesses[i].location;
|
||||
|
||||
if (addressDetails.display_address) {
|
||||
var displayAddressArr = addressDetails.display_address;
|
||||
|
||||
if (Array.isArray(displayAddressArr)) {
|
||||
for (var j = 0; j < displayAddressArr.length; j++) {
|
||||
var displayAddressPart = displayAddressArr[j];
|
||||
displayAddress = displayAddress + displayAddressPart;
|
||||
|
||||
if (j != displayAddressArr.length - 1) {
|
||||
displayAddress = displayAddress + ', ';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
displayAddress = displayAddressArr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (businesses[i].distance) {
|
||||
var distance = businesses[i].distance;
|
||||
var distanceMiles = (distance * 0.000621371192).toFixed(2);
|
||||
}
|
||||
|
||||
if (isClosed != true) {
|
||||
records.push({
|
||||
id,
|
||||
alias,
|
||||
url,
|
||||
imageURL,
|
||||
name,
|
||||
phone,
|
||||
price,
|
||||
rating,
|
||||
latitude,
|
||||
longitude,
|
||||
displayAddress,
|
||||
isOpen,
|
||||
distance: distanceMiles,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
};
|
@@ -0,0 +1,15 @@
|
||||
import { Store } from 'pullstate';
|
||||
|
||||
const RecordsStore = new Store({
|
||||
|
||||
records: [],
|
||||
center : []
|
||||
});
|
||||
|
||||
export default RecordsStore;
|
||||
|
||||
export const setStore = records => {
|
||||
|
||||
RecordsStore.update(state => { state.records = records.allRecords });
|
||||
RecordsStore.update(state => { state.center = records.center });
|
||||
}
|
@@ -0,0 +1,15 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
const getState = state => state;
|
||||
|
||||
// General getters
|
||||
export const fetchRecords = createSelector(getState, state => state.records);
|
||||
|
||||
// More specific getters
|
||||
export const fetchRecord = recordId => createSelector(getState, state => {
|
||||
|
||||
return state.records.filter(record => record.id === recordId)[0];
|
||||
});
|
||||
// export const getPoll = pollId => createSelector(getState, state => state.polls.filter(poll => poll.id === parseInt(pollId))[0]);
|
||||
// export const getChat = contactId => createSelector(getState, state => state.chats.filter(c => parseInt(c.contact_id) === parseInt(contactId))[0].chats);
|
||||
// export const getContact = contactId => createSelector(getState, state => state.contacts.filter(c => parseInt(c.id) === parseInt(contactId))[0]);
|
@@ -0,0 +1 @@
|
||||
export { default as RecordsStore } from "./RecordsStore";
|
103
03_source/mobile/src/pages/DemoRestaurantFinder/style.scss
Normal file
103
03_source/mobile/src/pages/DemoRestaurantFinder/style.scss
Normal file
@@ -0,0 +1,103 @@
|
||||
#about-page {
|
||||
ion-toolbar {
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
--background: transparent;
|
||||
--color: white;
|
||||
}
|
||||
|
||||
ion-toolbar ion-back-button,
|
||||
ion-toolbar ion-button,
|
||||
ion-toolbar ion-menu-button {
|
||||
--color: white;
|
||||
}
|
||||
|
||||
.about-header {
|
||||
position: relative;
|
||||
|
||||
width: 100%;
|
||||
height: 30%;
|
||||
}
|
||||
|
||||
.about-header .about-image {
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
transition: opacity 500ms ease-in-out;
|
||||
}
|
||||
|
||||
.about-header .madison {
|
||||
background-image: url('/assets/WeatherDemo/img/about/madison.jpg');
|
||||
}
|
||||
|
||||
.about-header .austin {
|
||||
background-image: url('/assets/WeatherDemo/img/about/austin.jpg');
|
||||
}
|
||||
|
||||
.about-header .chicago {
|
||||
background-image: url('/assets/WeatherDemo/img/about/chicago.jpg');
|
||||
}
|
||||
|
||||
.about-header .seattle {
|
||||
background-image: url('/assets/WeatherDemo/img/about/seattle.jpg');
|
||||
}
|
||||
|
||||
.about-info {
|
||||
position: relative;
|
||||
margin-top: -10px;
|
||||
border-radius: 10px;
|
||||
background: var(--ion-background-color, #fff);
|
||||
z-index: 2; // display rounded border above header image
|
||||
}
|
||||
|
||||
.about-info h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.about-info ion-list {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.about-info p {
|
||||
line-height: 130%;
|
||||
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
|
||||
.about-info ion-icon {
|
||||
margin-inline-end: 32px;
|
||||
}
|
||||
|
||||
/*
|
||||
* iOS Only
|
||||
*/
|
||||
|
||||
.ios .about-info {
|
||||
--ion-padding: 19px;
|
||||
}
|
||||
|
||||
.ios .about-info h3 {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
#date-input-popover {
|
||||
--offset-y: -var(--ion-safe-area-bottom);
|
||||
|
||||
--max-width: 90%;
|
||||
--width: 336px;
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
.overlaySearch {
|
||||
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
background-color: white;
|
||||
width: 70%;
|
||||
height: 3rem;
|
||||
margin: 0 auto;
|
||||
margin-left: 14%;
|
||||
|
||||
border-radius: 10px;
|
||||
box-shadow: rgba(149, 157, 165, 0.2) 0px 8px 24px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
}
|
@@ -0,0 +1,60 @@
|
||||
.overlayContainer {
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// align-items: center;
|
||||
// align-content: center;
|
||||
|
||||
padding: 1rem;
|
||||
|
||||
width: 12rem;
|
||||
height: fit-content;
|
||||
background-color: white;
|
||||
|
||||
border-radius: 5px;
|
||||
|
||||
box-shadow: rgba(149, 157, 165, 0.2) 0px 8px 24px;
|
||||
|
||||
ion-card-subtitle {
|
||||
|
||||
font-size: 0.7rem;
|
||||
color: black;
|
||||
}
|
||||
|
||||
ion-note {
|
||||
|
||||
font-size: 0.6rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
ion-badge {
|
||||
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.overlayContainer:after {
|
||||
|
||||
content:'';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 70%;
|
||||
margin-left: -50px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: solid 10px white;
|
||||
border-left: solid 10px transparent;
|
||||
border-right: solid 10px transparent;
|
||||
}
|
@@ -0,0 +1,17 @@
|
||||
.star {
|
||||
|
||||
background-color: var(--ion-color-primary);
|
||||
padding: 0.2rem;
|
||||
margin-right: 0.1rem;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.outlineStar {
|
||||
|
||||
background-color: rgb(216, 216, 216);
|
||||
color: white;
|
||||
padding: 0.2rem;
|
||||
margin-right: 0.1rem;
|
||||
border-radius: 4px;
|
||||
}
|
@@ -0,0 +1,54 @@
|
||||
.viewCard {
|
||||
|
||||
p {
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
|
||||
ion-icon {
|
||||
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.cardImage {
|
||||
|
||||
height: 10rem;
|
||||
width: 100%;
|
||||
background-position: top center;
|
||||
background-size: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.viewCardModal {
|
||||
|
||||
p {
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
|
||||
ion-icon {
|
||||
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.cardImage {
|
||||
|
||||
height: 10rem;
|
||||
width: 100%;
|
||||
background-position: top center;
|
||||
background-size: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.viewCardContainerModal {
|
||||
|
||||
height: 90vh;
|
||||
width: 100%;
|
||||
overflow: scroll;
|
||||
}
|
@@ -0,0 +1,81 @@
|
||||
.page {
|
||||
|
||||
ion-content {
|
||||
|
||||
ion-toolbar,
|
||||
ion-header {
|
||||
|
||||
--border-style: none;
|
||||
--border-color: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cardImage {
|
||||
|
||||
height: 10rem;
|
||||
width: 100%;
|
||||
background-position: top center;
|
||||
background-size: cover;
|
||||
border-radius: 5px;
|
||||
|
||||
ion-row {
|
||||
|
||||
--color: white;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
ion-card-subtitle {
|
||||
|
||||
--color: white;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
font-size: 1.2rem;
|
||||
|
||||
div {
|
||||
|
||||
margin-bottom: -0.6rem;
|
||||
}
|
||||
|
||||
ion-note {
|
||||
|
||||
font-size: 0.6rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
--color: white;
|
||||
text-transform: lowercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.categoryContainer {
|
||||
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid rgb(241, 241, 241);
|
||||
}
|
||||
|
||||
.placePhoto {
|
||||
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.reviewUser {
|
||||
|
||||
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user