This commit is contained in:
goro 2026-06-14 05:53:50 +03:00
parent f91a5590db
commit 148eaf88c9
13 changed files with 691 additions and 35 deletions

View File

@ -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>

View File

@ -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;

View File

@ -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,
}

View 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>
);
}

View File

@ -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)}
/>
)}
</>
)
}

View File

@ -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>,
}

View 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>
);
}

View File

@ -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>
)

View File

@ -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;
}

View File

@ -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>

View File

@ -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) {

View File

@ -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,

View File

@ -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,