updtae
This commit is contained in:
parent
f91a5590db
commit
148eaf88c9
@ -1,6 +1,7 @@
|
||||
import { JSXInternal } from "node_modules/preact/src/jsx";
|
||||
import { LocationInfo } from "../../../domains";
|
||||
import { cn } from "../../../utils/common";
|
||||
import FallbackImage from "../../../../src/components/Img/FallbackImage";
|
||||
|
||||
interface ComponentProps {
|
||||
onCardClick: (id: number) => void,
|
||||
@ -37,15 +38,11 @@ const LocationCard = (props: ComponentProps) => {
|
||||
<div className={props.containerClass} style={props.containerStyle}>
|
||||
<a onClick={() => props.onCardClick(props.data.id)}>
|
||||
<div className={'border-secondary recently-img-container'}>
|
||||
<img
|
||||
<FallbackImage
|
||||
thumbnail={props.data.thumbnail}
|
||||
locationType={props.data.location_type}
|
||||
alt={props.data.name}
|
||||
src={props.data.thumbnail ? props.data.thumbnail : ''}
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp';
|
||||
e.currentTarget.onerror = null;
|
||||
}}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@ -15,7 +15,7 @@ const FallbackImage = ({ thumbnail, locationType, style, alt }: FallbackImagePro
|
||||
style={style}
|
||||
alt={alt}
|
||||
onError={(e) => {
|
||||
if (locationType === 'restaurant') {
|
||||
if (locationType === 'restaurant' || locationType === 'culinary') {
|
||||
e.currentTarget.src = restaurantThumbnailSrc;
|
||||
} else {
|
||||
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_SEARCH_LOCATIONS_URI = `${BASE_URL}/locations/search`;
|
||||
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_LOCATION_URI = `${BASE_URL}/location`;
|
||||
const GET_LOCATION_TAGS_URI = `${BASE_URL}/location/tags`;
|
||||
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_MENU_ITEMS_URI = `${BASE_URL}/menu-items`;
|
||||
|
||||
const POST_REVIEW_LOCATION_URI = `${BASE_URL}/review/location`;
|
||||
const POST_REVIEW_IMAGES_URI = `${BASE_URL}/review/location/images`;
|
||||
@ -43,6 +46,7 @@ export {
|
||||
GET_CURRENT_USER_STATS,
|
||||
GET_LIST_RECENT_LOCATIONS_RATING_URI,
|
||||
GET_LIST_TOP_LOCATIONS,
|
||||
GET_LIST_TRENDING_LOCATIONS_URI,
|
||||
GET_LIST_LOCATIONS_URI,
|
||||
GET_LOCATION_URI,
|
||||
GET_SEARCH_LOCATIONS_URI,
|
||||
@ -54,5 +58,7 @@ export {
|
||||
POST_REVIEW_LOCATION_URI,
|
||||
POST_REVIEW_IMAGES_URI,
|
||||
POST_CREATE_LOCATION,
|
||||
POST_LOCATION_VISIT_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 AmenitiesModal from "./AmenitiesModal";
|
||||
import MenuItemsModal from "./MenuItemsModal";
|
||||
import BusinessHoursModal from "./BusinessHoursModal";
|
||||
|
||||
function AddLocation() {
|
||||
const [recentLocations, setRecentLocations] = useState<Array<LocationInfo>>()
|
||||
@ -29,9 +30,11 @@ function AddLocation() {
|
||||
thumbnails: [],
|
||||
amenities: [],
|
||||
restaurant_menu: [],
|
||||
business_hours: [],
|
||||
})
|
||||
const [showAmenitiesModal, setShowAmenitiesModal] = useState(false)
|
||||
const [showMenuModal, setShowMenuModal] = useState(false)
|
||||
const [showBusinessHoursModal, setShowBusinessHoursModal] = useState(false)
|
||||
const [submitLoading, setSubmitLoading ] = useState<boolean>(false)
|
||||
|
||||
const user = useSelector((state: UserRootState) => state.auth)
|
||||
@ -69,7 +72,7 @@ function AddLocation() {
|
||||
|
||||
function onChangeLocationType(e: ChangeEvent) {
|
||||
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) {
|
||||
@ -101,6 +104,9 @@ function AddLocation() {
|
||||
if (form.restaurant_menu.length > 0) {
|
||||
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++) {
|
||||
formData.append("thumbnail", tempThumbnailArr[i])
|
||||
@ -239,6 +245,22 @@ function AddLocation() {
|
||||
)}
|
||||
</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>
|
||||
<DropdownInput
|
||||
isSearchable={true}
|
||||
@ -312,6 +334,13 @@ function AddLocation() {
|
||||
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,
|
||||
}
|
||||
|
||||
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 {
|
||||
name: string,
|
||||
address: string,
|
||||
@ -22,4 +38,5 @@ export interface Form {
|
||||
thumbnails: Array<Thumbnail>,
|
||||
amenities: Array<Record<string, string[]>>,
|
||||
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 { UserStar, User, Map, HandPlatter, ClockCheck } from 'lucide-react';
|
||||
import { Link } from "react-router-dom";
|
||||
import FallbackImage from "../../../src/components/Img/FallbackImage";
|
||||
import MenuPopup from "./MenuPopup";
|
||||
|
||||
interface TopLocation {
|
||||
row_number: number,
|
||||
@ -57,6 +59,7 @@ function getRatingColorClass(score: number): string {
|
||||
function BestLocation() {
|
||||
const [page, _setPage] = useState<number>(1);
|
||||
const [topLocations, setTopLocations] = useState<Array<TopLocation>>([])
|
||||
const [menuPopup, setMenuPopup] = useState<{ id: number; name: string } | null>(null)
|
||||
const [pageState, setPageState] = useState({
|
||||
filterScoreType: 'all',
|
||||
filterScoreTypeidx: 1,
|
||||
@ -151,14 +154,11 @@ const [page, _setPage] = useState<number>(1);
|
||||
</div>
|
||||
<div style={{ maxWidth: 215, margin: '0 30px 0px 10px', float: 'left' }}>
|
||||
<Link to={`/location/${x.id}`}>
|
||||
<img
|
||||
src={x.thumbnail}
|
||||
loading={'lazy'}
|
||||
style={{ width: '100%', objectFit: 'cover', height: '100%', aspectRatio: '1/1' }}
|
||||
onError={(e) => {
|
||||
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;
|
||||
}}
|
||||
<FallbackImage
|
||||
thumbnail={x.thumbnail}
|
||||
locationType={x.location_type}
|
||||
alt={x.name}
|
||||
style={{ height: '100%', width: '100%', objectFit: 'cover', aspectRatio: '1/1' }}
|
||||
/>
|
||||
</Link>
|
||||
{/* <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 mt-2 flex flex-row flex-wrap gap-2'}>
|
||||
{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>
|
||||
</div>
|
||||
@ -229,6 +235,14 @@ const [page, _setPage] = useState<number>(1);
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{menuPopup && (
|
||||
<MenuPopup
|
||||
locationId={menuPopup.id}
|
||||
locationName={menuPopup.name}
|
||||
onClose={() => setMenuPopup(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@ -60,4 +60,206 @@ a .selected-reviewer-filter:hover{
|
||||
position: sticky;
|
||||
align-self: flex-start;
|
||||
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 popular from '../../datas/popular.json';
|
||||
import './style.css';
|
||||
@ -127,21 +128,17 @@ function Home() {
|
||||
{popular.data.map((x) => (
|
||||
<div className={"m-2 text-sm col-span-2 md:col-span-1"}>
|
||||
<div className={"mb-2 trending-image-container"}>
|
||||
<img
|
||||
src={x.thumbnail}
|
||||
loading={"lazy"}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = 'https://pub-6b637ea51b64436dbf0514bc956972d1.r2.dev/public/upload/misty-forest-black-white.webp';
|
||||
e.currentTarget.onerror = null;
|
||||
}}
|
||||
/>
|
||||
<FallbackImage
|
||||
thumbnail={x.thumbnail}
|
||||
locationType={x.location_type}
|
||||
alt={x.name}
|
||||
style={{ aspectRatio: '1/1'}}
|
||||
/>
|
||||
</div >
|
||||
<p className={"location-title"}>{x.name}</p>
|
||||
<p className={"text-xs location-title"}>{x.location}</p>
|
||||
<p className={"text-xs location-title"}>1.2k users visit this month</p>
|
||||
<div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -152,7 +149,13 @@ function Home() {
|
||||
{topCriticsLocations.map((x) => (
|
||||
<div className={"pt-2 text-sm top-location-container"}>
|
||||
<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}
|
||||
loading={'lazy'}
|
||||
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.onerror = null;
|
||||
}}
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
<p className={'location-title'}>{x.name}</p>
|
||||
<p className={'text-xs location-province location-title'}>{x.regency_name}</p>
|
||||
@ -183,7 +186,13 @@ function Home() {
|
||||
{topUsersLocations.map((x) => (
|
||||
<div className={"pt-2 text-sm top-location-container"}>
|
||||
<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}
|
||||
loading={'lazy'}
|
||||
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.onerror = null;
|
||||
}}
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
<p className={'location-title'}>{x.name}</p>
|
||||
<p className={'text-xs location-province location-title'}>{x.regency_name}</p>
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
} from './types';
|
||||
import { handleApiError, useAutosizeTextArea } from '../../utils';
|
||||
import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation, postReviewImages } from "../../services";
|
||||
import { recordLocationVisitService } from "../../services/locations";
|
||||
import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading, ReviewCard, ReviewCardFull } from '../../components';
|
||||
import RatingsCard from '../../components/Card/RatingsCard';
|
||||
import { useSelector } from 'react-redux';
|
||||
@ -112,6 +113,11 @@ function LocationDetail() {
|
||||
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) {
|
||||
const err = error as IHttpResponse;
|
||||
if (err.status == 404) {
|
||||
|
||||
@ -7,6 +7,7 @@ export interface ILocationDetail {
|
||||
address: String,
|
||||
regency_name: String,
|
||||
province_name: String,
|
||||
location_type: String,
|
||||
region_name: String,
|
||||
google_maps_link: String,
|
||||
thumbnail: string | null,
|
||||
@ -27,6 +28,7 @@ export function emptyLocationDetail(): ILocationDetail {
|
||||
province_name: '',
|
||||
regency_name: '',
|
||||
region_name: '',
|
||||
location_type: '',
|
||||
submitted_by: 0,
|
||||
critic_score: 0,
|
||||
critic_count: 0,
|
||||
|
||||
@ -4,10 +4,13 @@ import {
|
||||
GET_LIST_LOCATIONS_URI,
|
||||
GET_LIST_RECENT_LOCATIONS_RATING_URI,
|
||||
GET_LIST_TOP_LOCATIONS,
|
||||
GET_LIST_TRENDING_LOCATIONS_URI,
|
||||
GET_LOCATION_TAGS_URI,
|
||||
GET_LOCATION_URI,
|
||||
GET_SEARCH_LOCATIONS_URI,
|
||||
POST_CREATE_LOCATION
|
||||
POST_CREATE_LOCATION,
|
||||
POST_LOCATION_VISIT_URI,
|
||||
GET_MENU_ITEMS_URI,
|
||||
} from "../constants/api";
|
||||
import { client } from "./config";
|
||||
|
||||
@ -170,6 +173,7 @@ async function getListTopLocationsService(params: GetListLocationsArg) {
|
||||
async function getLocationService(params: GetLocationArg) {
|
||||
try {
|
||||
const data = await fetchLocation(params)
|
||||
console.log(data)
|
||||
return { data, error: null }
|
||||
} catch (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 {
|
||||
getListLocationsService,
|
||||
getSearchLocationService,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user