updtae
This commit is contained in:
parent
f91a5590db
commit
148eaf88c9
@ -1,6 +1,7 @@
|
|||||||
import { JSXInternal } from "node_modules/preact/src/jsx";
|
import { JSXInternal } from "node_modules/preact/src/jsx";
|
||||||
import { LocationInfo } from "../../../domains";
|
import { LocationInfo } from "../../../domains";
|
||||||
import { cn } from "../../../utils/common";
|
import { cn } from "../../../utils/common";
|
||||||
|
import FallbackImage from "../../../../src/components/Img/FallbackImage";
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
onCardClick: (id: number) => void,
|
onCardClick: (id: number) => void,
|
||||||
@ -37,15 +38,11 @@ const LocationCard = (props: ComponentProps) => {
|
|||||||
<div className={props.containerClass} style={props.containerStyle}>
|
<div className={props.containerClass} style={props.containerStyle}>
|
||||||
<a onClick={() => props.onCardClick(props.data.id)}>
|
<a onClick={() => props.onCardClick(props.data.id)}>
|
||||||
<div className={'border-secondary recently-img-container'}>
|
<div className={'border-secondary recently-img-container'}>
|
||||||
<img
|
<FallbackImage
|
||||||
|
thumbnail={props.data.thumbnail}
|
||||||
|
locationType={props.data.location_type}
|
||||||
alt={props.data.name}
|
alt={props.data.name}
|
||||||
src={props.data.thumbnail ? props.data.thumbnail : ''}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
loading="lazy"
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.src = 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp';
|
|
||||||
e.currentTarget.onerror = null;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const FallbackImage = ({ thumbnail, locationType, style, alt }: FallbackImagePro
|
|||||||
style={style}
|
style={style}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
if (locationType === 'restaurant') {
|
if (locationType === 'restaurant' || locationType === 'culinary') {
|
||||||
e.currentTarget.src = restaurantThumbnailSrc;
|
e.currentTarget.src = restaurantThumbnailSrc;
|
||||||
} else {
|
} else {
|
||||||
e.currentTarget.src = fallbackThumbnailSrc;
|
e.currentTarget.src = fallbackThumbnailSrc;
|
||||||
|
|||||||
@ -19,12 +19,15 @@ const POST_NEWS_EVENTS_URI = GET_NEWS_EVENTS_URI;
|
|||||||
const GET_LIST_LOCATIONS_URI = `${BASE_URL}/locations`;
|
const GET_LIST_LOCATIONS_URI = `${BASE_URL}/locations`;
|
||||||
const GET_SEARCH_LOCATIONS_URI = `${BASE_URL}/locations/search`;
|
const GET_SEARCH_LOCATIONS_URI = `${BASE_URL}/locations/search`;
|
||||||
const GET_LIST_TOP_LOCATIONS = `${BASE_URL}/locations/top-ratings`;
|
const GET_LIST_TOP_LOCATIONS = `${BASE_URL}/locations/top-ratings`;
|
||||||
|
const GET_LIST_TRENDING_LOCATIONS_URI = `${BASE_URL}/locations/trending`;
|
||||||
const GET_LIST_RECENT_LOCATIONS_RATING_URI = `${BASE_URL}/locations/recent`;
|
const GET_LIST_RECENT_LOCATIONS_RATING_URI = `${BASE_URL}/locations/recent`;
|
||||||
const GET_LOCATION_URI = `${BASE_URL}/location`;
|
const GET_LOCATION_URI = `${BASE_URL}/location`;
|
||||||
const GET_LOCATION_TAGS_URI = `${BASE_URL}/location/tags`;
|
const GET_LOCATION_TAGS_URI = `${BASE_URL}/location/tags`;
|
||||||
const POST_CREATE_LOCATION = `${BASE_URL}/location`;
|
const POST_CREATE_LOCATION = `${BASE_URL}/location`;
|
||||||
|
const POST_LOCATION_VISIT_URI = `${BASE_URL}/location`;
|
||||||
|
|
||||||
const GET_IMAGES_BY_LOCATION_URI = `${BASE_URL}/images/location`;
|
const GET_IMAGES_BY_LOCATION_URI = `${BASE_URL}/images/location`;
|
||||||
|
const GET_MENU_ITEMS_URI = `${BASE_URL}/menu-items`;
|
||||||
|
|
||||||
const POST_REVIEW_LOCATION_URI = `${BASE_URL}/review/location`;
|
const POST_REVIEW_LOCATION_URI = `${BASE_URL}/review/location`;
|
||||||
const POST_REVIEW_IMAGES_URI = `${BASE_URL}/review/location/images`;
|
const POST_REVIEW_IMAGES_URI = `${BASE_URL}/review/location/images`;
|
||||||
@ -43,6 +46,7 @@ export {
|
|||||||
GET_CURRENT_USER_STATS,
|
GET_CURRENT_USER_STATS,
|
||||||
GET_LIST_RECENT_LOCATIONS_RATING_URI,
|
GET_LIST_RECENT_LOCATIONS_RATING_URI,
|
||||||
GET_LIST_TOP_LOCATIONS,
|
GET_LIST_TOP_LOCATIONS,
|
||||||
|
GET_LIST_TRENDING_LOCATIONS_URI,
|
||||||
GET_LIST_LOCATIONS_URI,
|
GET_LIST_LOCATIONS_URI,
|
||||||
GET_LOCATION_URI,
|
GET_LOCATION_URI,
|
||||||
GET_SEARCH_LOCATIONS_URI,
|
GET_SEARCH_LOCATIONS_URI,
|
||||||
@ -54,5 +58,7 @@ export {
|
|||||||
POST_REVIEW_LOCATION_URI,
|
POST_REVIEW_LOCATION_URI,
|
||||||
POST_REVIEW_IMAGES_URI,
|
POST_REVIEW_IMAGES_URI,
|
||||||
POST_CREATE_LOCATION,
|
POST_CREATE_LOCATION,
|
||||||
|
POST_LOCATION_VISIT_URI,
|
||||||
POST_NEWS_EVENTS_URI,
|
POST_NEWS_EVENTS_URI,
|
||||||
}
|
GET_MENU_ITEMS_URI,
|
||||||
|
}
|
||||||
|
|||||||
166
src/pages/AddLocation/BusinessHoursModal.tsx
Normal file
166
src/pages/AddLocation/BusinessHoursModal.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from "preact/compat";
|
||||||
|
import { BusinessHourEntry, DayOfWeek } from "./types";
|
||||||
|
|
||||||
|
const DAYS: DayOfWeek[] = [
|
||||||
|
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
|
|
||||||
|
interface BusinessHoursModalProps {
|
||||||
|
selected: Array<BusinessHourEntry>;
|
||||||
|
onSave: (entries: Array<BusinessHourEntry>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInitialState(selected: Array<BusinessHourEntry>): Record<DayOfWeek, BusinessHourEntry> {
|
||||||
|
const byDay = new Map(selected.map(e => [e.day, e]));
|
||||||
|
const init = {} as Record<DayOfWeek, BusinessHourEntry>;
|
||||||
|
for (const day of DAYS) {
|
||||||
|
const existing = byDay.get(day);
|
||||||
|
init[day] = existing
|
||||||
|
? { ...existing }
|
||||||
|
: { day, open: '09:00', close: '21:00', closed: false };
|
||||||
|
}
|
||||||
|
return init;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BusinessHoursModal({ selected, onSave, onClose }: BusinessHoursModalProps) {
|
||||||
|
const [entries, setEntries] = useState<Record<DayOfWeek, BusinessHourEntry>>(
|
||||||
|
() => buildInitialState(selected)
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
function toggleClosed(day: DayOfWeek) {
|
||||||
|
setEntries(prev => ({
|
||||||
|
...prev,
|
||||||
|
[day]: { ...prev[day], closed: !prev[day].closed },
|
||||||
|
}));
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTime(day: DayOfWeek, field: 'open' | 'close', value: string) {
|
||||||
|
setEntries(prev => ({
|
||||||
|
...prev,
|
||||||
|
[day]: { ...prev[day], [field]: value },
|
||||||
|
}));
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToAll(day: DayOfWeek) {
|
||||||
|
const src = entries[day];
|
||||||
|
setEntries(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const d of DAYS) {
|
||||||
|
next[d] = { ...src, day: d };
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
// Validate every non-closed day.
|
||||||
|
for (const day of DAYS) {
|
||||||
|
const e = entries[day];
|
||||||
|
if (e.closed) continue;
|
||||||
|
if (!TIME_RE.test(e.open)) {
|
||||||
|
setError(`${day}: invalid open time "${e.open}" (expected HH:MM, 24-hour)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!TIME_RE.test(e.close)) {
|
||||||
|
setError(`${day}: invalid close time "${e.close}" (expected HH:MM, 24-hour)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit canonical entries: drop open/close on closed days.
|
||||||
|
const out: Array<BusinessHourEntry> = DAYS.map(d => {
|
||||||
|
const e = entries[d];
|
||||||
|
return e.closed
|
||||||
|
? { day: d, open: '', close: '', closed: true }
|
||||||
|
: { day: d, open: e.open, close: e.close, closed: false };
|
||||||
|
});
|
||||||
|
onSave(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCount = DAYS.filter(d => !entries[d].closed).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="amenities-overlay" onClick={onClose}>
|
||||||
|
<div className="amenities-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="amenities-modal-header">
|
||||||
|
<span>Configure Business Hours</span>
|
||||||
|
<button className="amenities-close-btn" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="amenities-modal-body">
|
||||||
|
<p className="text-sm" style={{ opacity: 0.7, marginBottom: 12 }}>
|
||||||
|
Use 24-hour format (HH:MM). Toggle "Closed" for days the location is shut.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{DAYS.map(day => {
|
||||||
|
const e = entries[day];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className="amenities-section"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
<span style={{ minWidth: 90, fontWeight: 600 }}>{day}</span>
|
||||||
|
|
||||||
|
<label className="amenities-checkbox-label" style={{ margin: 0 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={e.closed}
|
||||||
|
onChange={() => toggleClosed(day)}
|
||||||
|
/>
|
||||||
|
<span>Closed</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
className="bg-primary text-sm amenities-custom-input"
|
||||||
|
style={{ width: 110, opacity: e.closed ? 0.4 : 1 }}
|
||||||
|
disabled={e.closed}
|
||||||
|
value={e.open}
|
||||||
|
onInput={ev => setTime(day, 'open', (ev.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
<span style={{ opacity: e.closed ? 0.4 : 0.7 }}>—</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
className="bg-primary text-sm amenities-custom-input"
|
||||||
|
style={{ width: 110, opacity: e.closed ? 0.4 : 1 }}
|
||||||
|
disabled={e.closed}
|
||||||
|
value={e.close}
|
||||||
|
onInput={ev => setTime(day, 'close', (ev.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="amenities-add-btn"
|
||||||
|
onClick={() => applyToAll(day)}
|
||||||
|
title={`Apply ${day}'s hours to every other day`}
|
||||||
|
>
|
||||||
|
Apply to all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-error text-sm" style={{ marginTop: 8 }}>{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="amenities-modal-footer">
|
||||||
|
<span className="text-sm" style={{ opacity: 0.6 }}>{setCount} day{setCount === 1 ? '' : 's'} open</span>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button className="amenities-cancel-btn" onClick={onClose}>Cancel</button>
|
||||||
|
<button className="amenities-save-btn" onClick={handleSave}>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ import DefaultLoadingAnimation from "../../components/LoadingAnimation/Default";
|
|||||||
import FallbackImage from "../../../src/components/Img/FallbackImage";
|
import FallbackImage from "../../../src/components/Img/FallbackImage";
|
||||||
import AmenitiesModal from "./AmenitiesModal";
|
import AmenitiesModal from "./AmenitiesModal";
|
||||||
import MenuItemsModal from "./MenuItemsModal";
|
import MenuItemsModal from "./MenuItemsModal";
|
||||||
|
import BusinessHoursModal from "./BusinessHoursModal";
|
||||||
|
|
||||||
function AddLocation() {
|
function AddLocation() {
|
||||||
const [recentLocations, setRecentLocations] = useState<Array<LocationInfo>>()
|
const [recentLocations, setRecentLocations] = useState<Array<LocationInfo>>()
|
||||||
@ -29,9 +30,11 @@ function AddLocation() {
|
|||||||
thumbnails: [],
|
thumbnails: [],
|
||||||
amenities: [],
|
amenities: [],
|
||||||
restaurant_menu: [],
|
restaurant_menu: [],
|
||||||
|
business_hours: [],
|
||||||
})
|
})
|
||||||
const [showAmenitiesModal, setShowAmenitiesModal] = useState(false)
|
const [showAmenitiesModal, setShowAmenitiesModal] = useState(false)
|
||||||
const [showMenuModal, setShowMenuModal] = useState(false)
|
const [showMenuModal, setShowMenuModal] = useState(false)
|
||||||
|
const [showBusinessHoursModal, setShowBusinessHoursModal] = useState(false)
|
||||||
const [submitLoading, setSubmitLoading ] = useState<boolean>(false)
|
const [submitLoading, setSubmitLoading ] = useState<boolean>(false)
|
||||||
|
|
||||||
const user = useSelector((state: UserRootState) => state.auth)
|
const user = useSelector((state: UserRootState) => state.auth)
|
||||||
@ -69,7 +72,7 @@ function AddLocation() {
|
|||||||
|
|
||||||
function onChangeLocationType(e: ChangeEvent) {
|
function onChangeLocationType(e: ChangeEvent) {
|
||||||
const value = (e.target as HTMLSelectElement).value as LocationType
|
const value = (e.target as HTMLSelectElement).value as LocationType
|
||||||
setForm({ ...form, location_type: value, amenities: [], restaurant_menu: [] })
|
setForm({ ...form, location_type: value, amenities: [], restaurant_menu: [], business_hours: [] })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmitForm(e: TargetedEvent) {
|
async function onSubmitForm(e: TargetedEvent) {
|
||||||
@ -101,6 +104,9 @@ function AddLocation() {
|
|||||||
if (form.restaurant_menu.length > 0) {
|
if (form.restaurant_menu.length > 0) {
|
||||||
formData.append("restaurant_menu", JSON.stringify(form.restaurant_menu))
|
formData.append("restaurant_menu", JSON.stringify(form.restaurant_menu))
|
||||||
}
|
}
|
||||||
|
if (form.business_hours.length > 0) {
|
||||||
|
formData.append("business_hours", JSON.stringify(form.business_hours))
|
||||||
|
}
|
||||||
|
|
||||||
for(let i = 0; i < tempThumbnailArr.length; i++) {
|
for(let i = 0; i < tempThumbnailArr.length; i++) {
|
||||||
formData.append("thumbnail", tempThumbnailArr[i])
|
formData.append("thumbnail", tempThumbnailArr[i])
|
||||||
@ -239,6 +245,22 @@ function AddLocation() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div>
|
||||||
|
<button type="button" className="amenities-trigger-btn" onClick={() => setShowBusinessHoursModal(true)}>
|
||||||
|
{form.business_hours.length > 0
|
||||||
|
? `Business Hours (${form.business_hours.filter(h => !h.closed).length}/7 open)`
|
||||||
|
: '+ Configure Business Hours'}
|
||||||
|
</button>
|
||||||
|
{form.business_hours.length > 0 && (
|
||||||
|
<div className="amenities-tags">
|
||||||
|
{form.business_hours.map(h => (
|
||||||
|
<span key={h.day} className="amenity-tag">
|
||||||
|
{h.day}: {h.closed ? 'Closed' : `${h.open}–${h.close}`}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<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>
|
<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
|
<DropdownInput
|
||||||
isSearchable={true}
|
isSearchable={true}
|
||||||
@ -312,6 +334,13 @@ function AddLocation() {
|
|||||||
onClose={() => setShowMenuModal(false)}
|
onClose={() => setShowMenuModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showBusinessHoursModal && (
|
||||||
|
<BusinessHoursModal
|
||||||
|
selected={form.business_hours}
|
||||||
|
onSave={(business_hours) => { setForm({ ...form, business_hours }); setShowBusinessHoursModal(false); }}
|
||||||
|
onClose={() => setShowBusinessHoursModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,22 @@ export interface MenuItem {
|
|||||||
description: string,
|
description: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DayOfWeek =
|
||||||
|
| 'Sunday'
|
||||||
|
| 'Monday'
|
||||||
|
| 'Tuesday'
|
||||||
|
| 'Wednesday'
|
||||||
|
| 'Thursday'
|
||||||
|
| 'Friday'
|
||||||
|
| 'Saturday';
|
||||||
|
|
||||||
|
export interface BusinessHourEntry {
|
||||||
|
day: DayOfWeek,
|
||||||
|
open: string, // "HH:MM" 24h
|
||||||
|
close: string, // "HH:MM" 24h
|
||||||
|
closed: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
export interface Form {
|
export interface Form {
|
||||||
name: string,
|
name: string,
|
||||||
address: string,
|
address: string,
|
||||||
@ -22,4 +38,5 @@ export interface Form {
|
|||||||
thumbnails: Array<Thumbnail>,
|
thumbnails: Array<Thumbnail>,
|
||||||
amenities: Array<Record<string, string[]>>,
|
amenities: Array<Record<string, string[]>>,
|
||||||
restaurant_menu: Array<MenuItem>,
|
restaurant_menu: Array<MenuItem>,
|
||||||
|
business_hours: Array<BusinessHourEntry>,
|
||||||
}
|
}
|
||||||
|
|||||||
133
src/pages/BestLocations/MenuPopup.tsx
Normal file
133
src/pages/BestLocations/MenuPopup.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import { getMenuItemsService } from "../../services/locations";
|
||||||
|
|
||||||
|
export interface MenuItemRow {
|
||||||
|
id: number;
|
||||||
|
location_id: number;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
is_available: boolean;
|
||||||
|
submitted_by: number;
|
||||||
|
avg_score: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
appetizer: 'Appetizer',
|
||||||
|
main_course: 'Main Course',
|
||||||
|
dessert: 'Dessert',
|
||||||
|
beverages: 'Beverages',
|
||||||
|
snack: 'Snack',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPrice(price: number): string {
|
||||||
|
return 'Rp' + price.toLocaleString('id-ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRatingColor(rating: number): string {
|
||||||
|
if (rating >= 70) return '#3ba55d';
|
||||||
|
if (rating >= 40) return '#faa61a';
|
||||||
|
return '#ed4245';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locationId: number;
|
||||||
|
locationName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MenuPopup({ locationId, locationName, onClose }: Props) {
|
||||||
|
const [items, setItems] = useState<MenuItemRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeCategory, setActiveCategory] = useState('all');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getMenuItemsService(locationId).then(res => {
|
||||||
|
if (res.data) setItems(res.data);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [locationId]);
|
||||||
|
|
||||||
|
const categories = ['all', ...Array.from(new Set(items.map(i => i.category).filter(Boolean)))];
|
||||||
|
|
||||||
|
const filtered = items.filter(item => {
|
||||||
|
const matchCat = activeCategory === 'all' || item.category === activeCategory;
|
||||||
|
const matchSearch = item.name.toLowerCase().includes(search.toLowerCase());
|
||||||
|
return matchCat && matchSearch;
|
||||||
|
});
|
||||||
|
|
||||||
|
// split into two columns
|
||||||
|
const mid = Math.ceil(filtered.length / 2);
|
||||||
|
const col1 = filtered.slice(0, mid);
|
||||||
|
const col2 = filtered.slice(mid);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="menu-popup-overlay" onClick={onClose}>
|
||||||
|
<div className="menu-popup-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
|
||||||
|
<div className="menu-popup-header">
|
||||||
|
<div>
|
||||||
|
<h2 className="menu-popup-title">{locationName} Menu</h2>
|
||||||
|
</div>
|
||||||
|
<div className="menu-popup-header-actions">
|
||||||
|
<input
|
||||||
|
className="menu-popup-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search dishes..."
|
||||||
|
value={search}
|
||||||
|
onInput={e => setSearch((e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
<button className="menu-popup-close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="menu-popup-tabs">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
className={`menu-popup-tab ${activeCategory === cat ? 'menu-popup-tab--active' : ''}`}
|
||||||
|
onClick={() => setActiveCategory(cat)}
|
||||||
|
>
|
||||||
|
{cat === 'all' ? 'All' : (CATEGORY_LABELS[cat] ?? cat)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="menu-popup-body">
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ padding: '20px', opacity: 0.5 }}>Loading...</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p style={{ padding: '20px', opacity: 0.5 }}>No items found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="menu-popup-grid">
|
||||||
|
{[col1, col2].map((col, ci) => (
|
||||||
|
<div key={ci} className="menu-popup-col">
|
||||||
|
<div className="menu-popup-col-header">
|
||||||
|
<span style={{ flex: 1 }}></span>
|
||||||
|
<span className="menu-popup-col-rating-label">Rating</span>
|
||||||
|
</div>
|
||||||
|
{col.map(item => (
|
||||||
|
<div key={item.id} className="menu-popup-item">
|
||||||
|
<span className="menu-popup-item-name">{item.name}</span>
|
||||||
|
<span className="menu-popup-item-price">{formatPrice(item.price)}</span>
|
||||||
|
{item.avg_score != null ? (
|
||||||
|
<span className="menu-popup-item-rating" style={{ color: getRatingColor(item.avg_score) }}>
|
||||||
|
{Math.round(item.avg_score)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="menu-popup-item-rating" style={{ opacity: 0.3 }}>—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,8 @@ import { DefaultSeparator } from "../../components";
|
|||||||
import './style.css';
|
import './style.css';
|
||||||
import { UserStar, User, Map, HandPlatter, ClockCheck } from 'lucide-react';
|
import { UserStar, User, Map, HandPlatter, ClockCheck } from 'lucide-react';
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import FallbackImage from "../../../src/components/Img/FallbackImage";
|
||||||
|
import MenuPopup from "./MenuPopup";
|
||||||
|
|
||||||
interface TopLocation {
|
interface TopLocation {
|
||||||
row_number: number,
|
row_number: number,
|
||||||
@ -57,6 +59,7 @@ function getRatingColorClass(score: number): string {
|
|||||||
function BestLocation() {
|
function BestLocation() {
|
||||||
const [page, _setPage] = useState<number>(1);
|
const [page, _setPage] = useState<number>(1);
|
||||||
const [topLocations, setTopLocations] = useState<Array<TopLocation>>([])
|
const [topLocations, setTopLocations] = useState<Array<TopLocation>>([])
|
||||||
|
const [menuPopup, setMenuPopup] = useState<{ id: number; name: string } | null>(null)
|
||||||
const [pageState, setPageState] = useState({
|
const [pageState, setPageState] = useState({
|
||||||
filterScoreType: 'all',
|
filterScoreType: 'all',
|
||||||
filterScoreTypeidx: 1,
|
filterScoreTypeidx: 1,
|
||||||
@ -151,14 +154,11 @@ const [page, _setPage] = useState<number>(1);
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ maxWidth: 215, margin: '0 30px 0px 10px', float: 'left' }}>
|
<div style={{ maxWidth: 215, margin: '0 30px 0px 10px', float: 'left' }}>
|
||||||
<Link to={`/location/${x.id}`}>
|
<Link to={`/location/${x.id}`}>
|
||||||
<img
|
<FallbackImage
|
||||||
src={x.thumbnail}
|
thumbnail={x.thumbnail}
|
||||||
loading={'lazy'}
|
locationType={x.location_type}
|
||||||
style={{ width: '100%', objectFit: 'cover', height: '100%', aspectRatio: '1/1' }}
|
alt={x.name}
|
||||||
onError={(e) => {
|
style={{ height: '100%', width: '100%', objectFit: 'cover', aspectRatio: '1/1' }}
|
||||||
e.currentTarget.src = x.location_type === 'culinary' ? 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/restaorunta.webp' : 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp';
|
|
||||||
e.currentTarget.onerror = null;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
{/* <div className={'md:hidden text-xs mt-2 flex flex-row flex-wrap gap-2'}>
|
{/* <div className={'md:hidden text-xs mt-2 flex flex-row flex-wrap gap-2'}>
|
||||||
@ -172,7 +172,13 @@ const [page, _setPage] = useState<number>(1);
|
|||||||
<div className={'text-xs md:text-sm h-[2rem] md:h-[2.625rem] underline'}><a className={'flex flex-row items-start gap-1'} href={x.google_maps_link} target="_"><Map className="w-4 h-4 flex-shrink-0 mt-0.5" /><span className="line-clamp-2">{x.address}</span></a></div>
|
<div className={'text-xs md:text-sm h-[2rem] md:h-[2.625rem] underline'}><a className={'flex flex-row items-start gap-1'} href={x.google_maps_link} target="_"><Map className="w-4 h-4 flex-shrink-0 mt-0.5" /><span className="line-clamp-2">{x.address}</span></a></div>
|
||||||
<div className={'text-xs mt-2 flex flex-row flex-wrap gap-2'}>
|
<div className={'text-xs mt-2 flex flex-row flex-wrap gap-2'}>
|
||||||
{x.location_type === 'culinary' &&
|
{x.location_type === 'culinary' &&
|
||||||
<a className={'flex flex-row items-center gap-1 border border-gray-600 rounded-full px-2 py-0.5 hover:bg-primary transition-colors'} href={x.google_maps_link} target="_"><HandPlatter className="w-3 h-3 flex-shrink-0" /><span className={'text-sm'}>Menu</span></a>
|
<button
|
||||||
|
className={'flex flex-row items-center gap-1 border border-gray-600 rounded-full px-2 py-0.5 hover:bg-primary transition-colors'}
|
||||||
|
style={{ background: 'none', color: 'white', cursor: 'pointer' }}
|
||||||
|
onClick={() => setMenuPopup({ id: x.id, name: x.name })}
|
||||||
|
>
|
||||||
|
<HandPlatter className="w-3 h-3 flex-shrink-0" /><span className={'text-sm'}>Menu</span>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
<a className={'flex flex-row items-center gap-1 border border-gray-600 rounded-full px-2 py-0.5 hover:bg-primary transition-colors'} href={x.google_maps_link} target="_"><ClockCheck className="w-3 h-3 flex-shrink-0" /><span className={'text-sm'}>Open</span></a>
|
<a className={'flex flex-row items-center gap-1 border border-gray-600 rounded-full px-2 py-0.5 hover:bg-primary transition-colors'} href={x.google_maps_link} target="_"><ClockCheck className="w-3 h-3 flex-shrink-0" /><span className={'text-sm'}>Open</span></a>
|
||||||
</div>
|
</div>
|
||||||
@ -229,6 +235,14 @@ const [page, _setPage] = useState<number>(1);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{menuPopup && (
|
||||||
|
<MenuPopup
|
||||||
|
locationId={menuPopup.id}
|
||||||
|
locationName={menuPopup.name}
|
||||||
|
onClose={() => setMenuPopup(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -60,4 +60,206 @@ a .selected-reviewer-filter:hover{
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
top: 30px;
|
top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu Popup */
|
||||||
|
.menu-popup-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-modal {
|
||||||
|
background: #1e2124;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 860px;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 20px 24px 14px;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-search {
|
||||||
|
background: #2f3136;
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 190px;
|
||||||
|
max-width: 40vw;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-search::placeholder {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.5;
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 24px 12px;
|
||||||
|
border-bottom: 1px solid #2f3136;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.55;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-tab:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
background: #2f3136;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-tab--active {
|
||||||
|
background: white;
|
||||||
|
color: #111;
|
||||||
|
opacity: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-tab--active:hover {
|
||||||
|
background: white;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0 32px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.menu-popup-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-header {
|
||||||
|
padding: 16px 16px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-tabs {
|
||||||
|
padding: 0 16px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-body {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-search {
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-col {}
|
||||||
|
|
||||||
|
.menu-popup-col-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0 4px;
|
||||||
|
border-bottom: 1px solid #2f3136;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-col-rating-label {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.45;
|
||||||
|
width: 44px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-bottom: 1px solid #2a2d31;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-item-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-item-price {
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-popup-item-rating {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
width: 28px;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import FallbackImage from '../../components/Img/FallbackImage';
|
||||||
import { LocationCard, SeparatorWithAnchor } from '../../components';
|
import { LocationCard, SeparatorWithAnchor } from '../../components';
|
||||||
import popular from '../../datas/popular.json';
|
import popular from '../../datas/popular.json';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
@ -127,21 +128,17 @@ function Home() {
|
|||||||
{popular.data.map((x) => (
|
{popular.data.map((x) => (
|
||||||
<div className={"m-2 text-sm col-span-2 md:col-span-1"}>
|
<div className={"m-2 text-sm col-span-2 md:col-span-1"}>
|
||||||
<div className={"mb-2 trending-image-container"}>
|
<div className={"mb-2 trending-image-container"}>
|
||||||
<img
|
<FallbackImage
|
||||||
src={x.thumbnail}
|
thumbnail={x.thumbnail}
|
||||||
loading={"lazy"}
|
locationType={x.location_type}
|
||||||
style={{ width: '100%', height: '100%' }}
|
alt={x.name}
|
||||||
onError={(e) => {
|
style={{ aspectRatio: '1/1'}}
|
||||||
e.currentTarget.src = 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp';
|
/>
|
||||||
e.currentTarget.onerror = null;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div >
|
</div >
|
||||||
<p className={"location-title"}>{x.name}</p>
|
<p className={"location-title"}>{x.name}</p>
|
||||||
<p className={"text-xs location-title"}>{x.location}</p>
|
<p className={"text-xs location-title"}>{x.location}</p>
|
||||||
<p className={"text-xs location-title"}>1.2k users visit this month</p>
|
<p className={"text-xs location-title"}>1.2k users visit this month</p>
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -152,7 +149,13 @@ function Home() {
|
|||||||
{topCriticsLocations.map((x) => (
|
{topCriticsLocations.map((x) => (
|
||||||
<div className={"pt-2 text-sm top-location-container"}>
|
<div className={"pt-2 text-sm top-location-container"}>
|
||||||
<div className={'mr-2 critics-users-image'}>
|
<div className={'mr-2 critics-users-image'}>
|
||||||
<img
|
<FallbackImage
|
||||||
|
thumbnail={x.thumbnail}
|
||||||
|
locationType={x.location_type}
|
||||||
|
alt={x.name}
|
||||||
|
style={{ height: '100%', width: '100%', borderRadius: 3 }}
|
||||||
|
/>
|
||||||
|
{/* <img
|
||||||
src={x.thumbnail}
|
src={x.thumbnail}
|
||||||
loading={'lazy'}
|
loading={'lazy'}
|
||||||
style={{ height: '100%', width: '100%', borderRadius: 3 }}
|
style={{ height: '100%', width: '100%', borderRadius: 3 }}
|
||||||
@ -160,7 +163,7 @@ function Home() {
|
|||||||
e.currentTarget.src = x.location_type === 'culinary' ? 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/restaorunta.webp' : 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp';
|
e.currentTarget.src = x.location_type === 'culinary' ? 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/restaorunta.webp' : 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp';
|
||||||
e.currentTarget.onerror = null;
|
e.currentTarget.onerror = null;
|
||||||
}}
|
}}
|
||||||
/>
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
<p className={'location-title'}>{x.name}</p>
|
<p className={'location-title'}>{x.name}</p>
|
||||||
<p className={'text-xs location-province location-title'}>{x.regency_name}</p>
|
<p className={'text-xs location-province location-title'}>{x.regency_name}</p>
|
||||||
@ -183,7 +186,13 @@ function Home() {
|
|||||||
{topUsersLocations.map((x) => (
|
{topUsersLocations.map((x) => (
|
||||||
<div className={"pt-2 text-sm top-location-container"}>
|
<div className={"pt-2 text-sm top-location-container"}>
|
||||||
<div className={'mr-2 critics-users-image'}>
|
<div className={'mr-2 critics-users-image'}>
|
||||||
<img
|
<FallbackImage
|
||||||
|
thumbnail={x.thumbnail}
|
||||||
|
locationType={x.location_type}
|
||||||
|
alt={x.name}
|
||||||
|
style={{ height: '100%', width: '100%', borderRadius: 3 }}
|
||||||
|
/>
|
||||||
|
{/* <img
|
||||||
src={x.thumbnail}
|
src={x.thumbnail}
|
||||||
loading={'lazy'}
|
loading={'lazy'}
|
||||||
style={{ height: '100%', width: '100%', borderRadius: 3 }}
|
style={{ height: '100%', width: '100%', borderRadius: 3 }}
|
||||||
@ -191,7 +200,7 @@ function Home() {
|
|||||||
e.currentTarget.src = x.location_type === 'culinary' ? 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/restaorunta.webp' : 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp';
|
e.currentTarget.src = x.location_type === 'culinary' ? 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/restaorunta.webp' : 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp';
|
||||||
e.currentTarget.onerror = null;
|
e.currentTarget.onerror = null;
|
||||||
}}
|
}}
|
||||||
/>
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
<p className={'location-title'}>{x.name}</p>
|
<p className={'location-title'}>{x.name}</p>
|
||||||
<p className={'text-xs location-province location-title'}>{x.regency_name}</p>
|
<p className={'text-xs location-province location-title'}>{x.regency_name}</p>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import { handleApiError, useAutosizeTextArea } from '../../utils';
|
import { handleApiError, useAutosizeTextArea } from '../../utils';
|
||||||
import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation, postReviewImages } from "../../services";
|
import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation, postReviewImages } from "../../services";
|
||||||
|
import { recordLocationVisitService } from "../../services/locations";
|
||||||
import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading, ReviewCard, ReviewCardFull } from '../../components';
|
import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading, ReviewCard, ReviewCardFull } from '../../components';
|
||||||
import RatingsCard from '../../components/Card/RatingsCard';
|
import RatingsCard from '../../components/Card/RatingsCard';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
@ -112,6 +113,11 @@ function LocationDetail() {
|
|||||||
getImage(val.detail.thumbnail)
|
getImage(val.detail.thumbnail)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// Fire-and-forget visit hit. Only fires once per (browser tab × location);
|
||||||
|
// the backend additionally dedupes by client IP for 30 minutes via Redis,
|
||||||
|
// so refreshes / multiple tabs / bots cannot inflate the trending counts.
|
||||||
|
// Anything that fails here is silently swallowed inside the service.
|
||||||
|
recordLocationVisitService(Number(id))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as IHttpResponse;
|
const err = error as IHttpResponse;
|
||||||
if (err.status == 404) {
|
if (err.status == 404) {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export interface ILocationDetail {
|
|||||||
address: String,
|
address: String,
|
||||||
regency_name: String,
|
regency_name: String,
|
||||||
province_name: String,
|
province_name: String,
|
||||||
|
location_type: String,
|
||||||
region_name: String,
|
region_name: String,
|
||||||
google_maps_link: String,
|
google_maps_link: String,
|
||||||
thumbnail: string | null,
|
thumbnail: string | null,
|
||||||
@ -27,6 +28,7 @@ export function emptyLocationDetail(): ILocationDetail {
|
|||||||
province_name: '',
|
province_name: '',
|
||||||
regency_name: '',
|
regency_name: '',
|
||||||
region_name: '',
|
region_name: '',
|
||||||
|
location_type: '',
|
||||||
submitted_by: 0,
|
submitted_by: 0,
|
||||||
critic_score: 0,
|
critic_score: 0,
|
||||||
critic_count: 0,
|
critic_count: 0,
|
||||||
|
|||||||
@ -4,10 +4,13 @@ import {
|
|||||||
GET_LIST_LOCATIONS_URI,
|
GET_LIST_LOCATIONS_URI,
|
||||||
GET_LIST_RECENT_LOCATIONS_RATING_URI,
|
GET_LIST_RECENT_LOCATIONS_RATING_URI,
|
||||||
GET_LIST_TOP_LOCATIONS,
|
GET_LIST_TOP_LOCATIONS,
|
||||||
|
GET_LIST_TRENDING_LOCATIONS_URI,
|
||||||
GET_LOCATION_TAGS_URI,
|
GET_LOCATION_TAGS_URI,
|
||||||
GET_LOCATION_URI,
|
GET_LOCATION_URI,
|
||||||
GET_SEARCH_LOCATIONS_URI,
|
GET_SEARCH_LOCATIONS_URI,
|
||||||
POST_CREATE_LOCATION
|
POST_CREATE_LOCATION,
|
||||||
|
POST_LOCATION_VISIT_URI,
|
||||||
|
GET_MENU_ITEMS_URI,
|
||||||
} from "../constants/api";
|
} from "../constants/api";
|
||||||
import { client } from "./config";
|
import { client } from "./config";
|
||||||
|
|
||||||
@ -170,6 +173,7 @@ async function getListTopLocationsService(params: GetListLocationsArg) {
|
|||||||
async function getLocationService(params: GetLocationArg) {
|
async function getLocationService(params: GetLocationArg) {
|
||||||
try {
|
try {
|
||||||
const data = await fetchLocation(params)
|
const data = await fetchLocation(params)
|
||||||
|
console.log(data)
|
||||||
return { data, error: null }
|
return { data, error: null }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error
|
throw error
|
||||||
@ -211,6 +215,77 @@ async function getSearchLocationService(arg: GetSearchLocations): Promise<IHttpR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchMenuItems = async (locationId: number) => {
|
||||||
|
const response = await client({ method: 'GET', url: `${GET_MENU_ITEMS_URI}?location_id=${locationId}` })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetTrendingLocationsArg extends GetRequestPagination {
|
||||||
|
/** week | month | 3month | semester | year */
|
||||||
|
window: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTrendingLocations = async ({ window, page, page_size }: GetTrendingLocationsArg) => {
|
||||||
|
const url = `${GET_LIST_TRENDING_LOCATIONS_URI}?window=${encodeURIComponent(window)}&page=${page}&page_size=${page_size}`
|
||||||
|
const response = await client({ method: 'GET', url })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTrendingLocations = (
|
||||||
|
params: GetTrendingLocationsArg,
|
||||||
|
options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>,
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['locations', 'trending', params],
|
||||||
|
queryFn: () => fetchTrendingLocations(params),
|
||||||
|
enabled: !!params.window,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTrendingLocationsService = async (params: GetTrendingLocationsArg) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchTrendingLocations(params)
|
||||||
|
return { data, error: null }
|
||||||
|
} catch (error: any) {
|
||||||
|
return { data: null, error, status: error?.status }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a page-visit hit for a location. The backend already de-duplicates
|
||||||
|
* by client IP (one count per IP per location per 30 minutes via Redis), so
|
||||||
|
* this is intentionally fire-and-forget. We additionally suppress repeats
|
||||||
|
* within the same browser tab/session so React re-renders, StrictMode double
|
||||||
|
* effects, or back/forward nav don't even hit the wire.
|
||||||
|
*/
|
||||||
|
const visitedThisSession = new Set<number>()
|
||||||
|
|
||||||
|
export const recordLocationVisitService = async (locationId: number): Promise<void> => {
|
||||||
|
if (!Number.isFinite(locationId) || locationId <= 0) return
|
||||||
|
if (visitedThisSession.has(locationId)) return
|
||||||
|
visitedThisSession.add(locationId)
|
||||||
|
try {
|
||||||
|
await client({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${POST_LOCATION_VISIT_URI}/${locationId}/visit`,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silent: visit counting must never disrupt the page render. The backend
|
||||||
|
// also responds 204 on dedup, so the only errors here are network/server.
|
||||||
|
visitedThisSession.delete(locationId) // allow a future retry on real failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMenuItemsService = async (locationId: number) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchMenuItems(locationId)
|
||||||
|
return { data, error: null }
|
||||||
|
} catch (error: any) {
|
||||||
|
return { data: null, error, status: error.status }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getListLocationsService,
|
getListLocationsService,
|
||||||
getSearchLocationService,
|
getSearchLocationService,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user